[Linux] New ear detection implementation (#145)

* New ear detection implementation

* [Linux] Improved case battery detection when not connected
This commit is contained in:
Tim Gromeyer
2025-06-07 09:19:14 +02:00
committed by GitHub
parent 3b20540c34
commit 5754dbfb16
9 changed files with 151 additions and 86 deletions

View File

@@ -31,6 +31,7 @@ qt_add_executable(applinux
thirdparty/QR-Code-generator/qrcodegen.cpp
thirdparty/QR-Code-generator/qrcodegen.hpp
QRCodeImageProvider.hpp
eardetection.hpp
)
qt_add_qml_module(applinux

View File

@@ -94,7 +94,7 @@ ApplicationWindow {
spacing: 8
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
@@ -103,7 +103,7 @@ ApplicationWindow {
}
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
@@ -112,7 +112,7 @@ ApplicationWindow {
}
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
visible: airPodsTrayApp.deviceInfo.battery.caseAvailable
inEar: true
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel

View File

@@ -1,16 +1,25 @@
import QtQuick 2.15
Column {
property bool isVisible: true
id: root
property bool inEar: true
property string iconSource
property int batteryLevel: 0
property bool isCharging: false
property string indicator: ""
property real targetOpacity: inEar ? 1 : 0.5
Timer {
id: opacityTimer
interval: 50
onTriggered: root.opacity = root.targetOpacity
}
onInEarChanged: {
opacityTimer.restart()
}
spacing: 5
opacity: inEar ? 1 : 0.5
visible: isVisible
Image {
source: parent.iconSource

View File

@@ -7,6 +7,7 @@
#include <climits>
#include "airpods_packets.h"
#include "logger.h"
class Battery : public QObject
{
@@ -128,10 +129,14 @@ public:
// Emit signal to notify about battery status change
emit batteryStatusChanged();
// Log which is left and right pod
LOG_INFO("Primary Pod:" << primaryPod);
LOG_INFO("Secondary Pod:" << secondaryPod);
return true;
}
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary)
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase)
{
// Validate packet size (expect 16 bytes based on provided payloads)
if (packet.size() != 16)
@@ -171,7 +176,9 @@ public:
// Update states
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
if (podInCase) {
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
}
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
emit batteryStatusChanged();

View File

@@ -5,6 +5,7 @@
#include <QSettings>
#include "battery.hpp"
#include "enums.h"
#include "eardetection.hpp"
using namespace AirpodsTrayApp::Enums;
@@ -12,14 +13,11 @@ class DeviceInfo : public QObject
{
Q_OBJECT
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus WRITE setEarDetectionStatus NOTIFY earDetectionStatusChanged)
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
Q_PROPERTY(bool primaryInEar READ isPrimaryInEar WRITE setPrimaryInEar NOTIFY primaryChanged)
Q_PROPERTY(bool secondaryInEar READ isSecondaryInEar WRITE setSecondaryInEar NOTIFY primaryChanged)
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt)
@@ -32,7 +30,9 @@ class DeviceInfo : public QObject
Q_PROPERTY(QString magicAccEncKey READ magicAccEncKeyHex CONSTANT)
public:
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)), m_earDetection(new EarDetection(this)) {
connect(getEarDetection(), &EarDetection::statusChanged, this, &DeviceInfo::primaryChanged);
}
QString batteryStatus() const { return m_batteryStatus; }
void setBatteryStatus(const QString &status)
@@ -44,16 +44,6 @@ public:
}
}
QString earDetectionStatus() const { return m_earDetectionStatus; }
void setEarDetectionStatus(const QString &status)
{
if (m_earDetectionStatus != status)
{
m_earDetectionStatus = status;
emit earDetectionStatusChanged(status);
}
}
NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
void setNoiseControlMode(NoiseControlMode mode)
{
@@ -99,26 +89,6 @@ public:
Battery *getBattery() const { return m_battery; }
bool isPrimaryInEar() const { return m_primaryInEar; }
void setPrimaryInEar(bool inEar)
{
if (m_primaryInEar != inEar)
{
m_primaryInEar = inEar;
emit primaryChanged();
}
}
bool isSecondaryInEar() const { return m_secoundaryInEar; }
void setSecondaryInEar(bool inEar)
{
if (m_secoundaryInEar != inEar)
{
m_secoundaryInEar = inEar;
emit primaryChanged();
}
}
bool oneBudANCMode() const { return m_oneBudANCMode; }
void setOneBudANCMode(bool enabled)
{
@@ -167,18 +137,18 @@ public:
QString caseIcon() const { return getModelIcon(model()).second; }
bool isLeftPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
else return isSecondaryInEar();
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return getEarDetection()->isPrimaryInEar();
else return getEarDetection()->isSecondaryInEar();
}
bool isRightPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
else return isSecondaryInEar();
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return getEarDetection()->isPrimaryInEar();
else return getEarDetection()->isSecondaryInEar();
}
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
EarDetection *getEarDetection() const { return m_earDetection; }
void reset()
{
@@ -186,11 +156,9 @@ public:
setModel(AirPodsModel::Unknown);
m_battery->reset();
setBatteryStatus("");
setEarDetectionStatus("");
setPrimaryInEar(false);
setSecondaryInEar(false);
setNoiseControlMode(NoiseControlMode::Off);
setBluetoothAddress("");
getEarDetection()->reset();
}
void saveToSettings(QSettings &settings)
@@ -210,9 +178,16 @@ public:
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
}
void updateBatteryStatus()
{
int leftLevel = getBattery()->getState(Battery::Component::Left).level;
int rightLevel = getBattery()->getState(Battery::Component::Right).level;
int caseLevel = getBattery()->getState(Battery::Component::Case).level;
setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel));
}
signals:
void batteryStatusChanged(const QString &status);
void earDetectionStatusChanged(const QString &status);
void noiseControlModeChanged(NoiseControlMode mode);
void noiseControlModeChangedInt(int mode);
void conversationalAwarenessChanged(bool enabled);
@@ -225,14 +200,11 @@ signals:
private:
QString m_batteryStatus;
QString m_earDetectionStatus;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
bool m_conversationalAwareness = false;
int m_adaptiveNoiseLevel = 50;
QString m_deviceName;
Battery *m_battery;
bool m_primaryInEar = false;
bool m_secoundaryInEar = false;
QByteArray m_magicAccIRK;
QByteArray m_magicAccEncKey;
bool m_oneBudANCMode = false;
@@ -240,4 +212,5 @@ private:
QString m_modelNumber;
QString m_manufacturer;
QString m_bluetoothAddress;
EarDetection *m_earDetection;
};

94
linux/eardetection.hpp Normal file
View File

@@ -0,0 +1,94 @@
#pragma once
#include <QObject>
#include <QByteArray>
#include <QPair>
#include "logger.h"
class EarDetection : public QObject
{
Q_OBJECT
public:
enum class EarDetectionStatus
{
InEar,
NotInEar,
InCase,
Disconnected,
};
Q_ENUM(EarDetectionStatus)
explicit EarDetection(QObject *parent = nullptr) : QObject(parent)
{
reset();
}
void reset()
{
primaryStatus = EarDetectionStatus::Disconnected;
secondaryStatus = EarDetectionStatus::Disconnected;
emit statusChanged();
}
bool parseData(const QByteArray &data)
{
if (data.size() < 2)
{
return false;
}
auto [newprimaryStatus, newsecondaryStatus] = parseStatusBytes(data);
primaryStatus = newprimaryStatus;
secondaryStatus = newsecondaryStatus;
LOG_DEBUG("Parsed Ear Detection Status: Primary - " << primaryStatus
<< ", Secondary - " << secondaryStatus);
emit statusChanged();
return true;
}
void overrideEarDetectionStatus(bool primaryInEar, bool secondaryInEar)
{
primaryStatus = primaryInEar ? EarDetectionStatus::InEar : EarDetectionStatus::NotInEar;
secondaryStatus = secondaryInEar ? EarDetectionStatus::InEar : EarDetectionStatus::NotInEar;
emit statusChanged();
}
bool isPrimaryInEar() const { return primaryStatus == EarDetectionStatus::InEar; }
bool isSecondaryInEar() const { return secondaryStatus == EarDetectionStatus::InEar; }
bool oneOrMorePodsInCase() const { return primaryStatus == EarDetectionStatus::InCase || secondaryStatus == EarDetectionStatus::InCase; }
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
EarDetectionStatus getprimaryStatus() const { return primaryStatus; }
EarDetectionStatus getsecondaryStatus() const { return secondaryStatus; }
signals:
void statusChanged();
private:
QPair<EarDetectionStatus, EarDetectionStatus> parseStatusBytes(const QByteArray &data) const
{
quint8 primaryByte = static_cast<quint8>(data[6]);
quint8 secondaryByte = static_cast<quint8>(data[7]);
auto primaryStatus = parseStatusByte(primaryByte);
auto secondaryStatus = parseStatusByte(secondaryByte);
return qMakePair(primaryStatus, secondaryStatus);
}
EarDetectionStatus parseStatusByte(quint8 byte) const
{
if (byte == 0x00)
return EarDetectionStatus::InEar;
if (byte == 0x01)
return EarDetectionStatus::NotInEar;
if (byte == 0x02)
return EarDetectionStatus::InCase;
return EarDetectionStatus::Disconnected;
}
EarDetectionStatus primaryStatus = EarDetectionStatus::Disconnected;
EarDetectionStatus secondaryStatus = EarDetectionStatus::Disconnected;
};

View File

@@ -65,7 +65,6 @@ public:
// Initialize MediaController and connect signals
mediaController = new MediaController(this);
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
mediaController->initializeMprisInterface();
mediaController->followMediaChanges();
@@ -590,26 +589,14 @@ private slots:
// Ear Detection
else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION))
{
char primary = data[6];
char secondary = data[7];
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
.arg(getEarStatus(primary), getEarStatus(secondary)));
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
m_deviceInfo->getEarDetection()->parseData(data);
mediaController->handleEarDetection(m_deviceInfo->getEarDetection());
}
// Battery Status
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
{
m_deviceInfo->getBattery()->parsePacket(data);
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
.arg(leftLevel)
.arg(rightLevel)
.arg(caseLevel));
m_deviceInfo->updateBatteryStatus();
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
}
// Conversational Awareness Data
@@ -623,7 +610,7 @@ private slots:
parseMetadata(data);
initiateMagicPairing();
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
if (m_deviceInfo->getEarDetection()->oneOrMorePodsInEar()) // AirPods get added as output device only after this
{
mediaController->activateA2dpProfile();
}
@@ -762,9 +749,8 @@ private slots:
if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) {
m_deviceInfo->setModel(device.modelName);
auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey());
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft);
m_deviceInfo->setPrimaryInEar(device.isPrimaryInEar);
m_deviceInfo->setSecondaryInEar(device.isSecondaryInEar);
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase);
m_deviceInfo->getEarDetection()->overrideEarDetectionStatus(device.isPrimaryInEar, device.isSecondaryInEar);
}
}

View File

@@ -1,5 +1,6 @@
#include "mediacontroller.h"
#include "logger.h"
#include "eardetection.hpp"
#include <QDebug>
#include <QProcess>
@@ -38,7 +39,7 @@ void MediaController::initializeMprisInterface() {
}
}
void MediaController::handleEarDetection(const QString &status)
void MediaController::handleEarDetection(EarDetection *earDetection)
{
if (earDetectionBehavior == Disabled)
{
@@ -46,15 +47,8 @@ void MediaController::handleEarDetection(const QString &status)
return;
}
bool primaryInEar = false;
bool secondaryInEar = false;
QStringList parts = status.split(", ");
if (parts.size() == 2)
{
primaryInEar = parts[0].contains("In Ear");
secondaryInEar = parts[1].contains("In Ear");
}
bool primaryInEar = earDetection->isPrimaryInEar();
bool secondaryInEar = earDetection->isSecondaryInEar();
LOG_DEBUG("Ear detection status: primaryInEar="
<< primaryInEar << ", secondaryInEar=" << secondaryInEar

View File

@@ -5,6 +5,7 @@
#include <QObject>
class QProcess;
class EarDetection;
class MediaController : public QObject
{
@@ -29,7 +30,7 @@ public:
~MediaController();
void initializeMprisInterface();
void handleEarDetection(const QString &status);
void handleEarDetection(EarDetection*);
void followMediaChanges();
bool isActiveOutputDeviceAirPods();
void handleConversationalAwareness(const QByteArray &data);