mirror of
https://github.com/arendst/Tasmota.git
synced 2026-10-26 22:11:22 +00:00
MI32: add Xiaomi Mi Body Composition Scale (MIBCS/MIBFS) support (#25049)
Decode the 13-byte body composition record the scale broadcasts as service data for UUID 0x181B (also sent as 0x2A9C notifications), and register the scale as MI32 device type MIBCS with fake product ID 0x181b. Per weighing session the driver publishes the weight once when the scale's stable flag is set and the impedance once when the impedance stable flag is set (repeating the weight so the message is self contained). If the user steps off before a usable impedance arrives, ImpedanceFailed:1 is published instead. A session ends on the finished flag or after 10 s of silence, so repeated broadcasts of the final record do not produce duplicate messages. Overload readings (0xFFF0) and the impedance sentinels 0xFFFE/0xFFFD are ignored. The unit (kg, lb, jin) is derived from the flag bits and published alongside. Web UI: weight and impedance rows on the main page; with USE_MI_EXT_GUI a dashboard tile with a 24 h weight history graph (kept in kg). Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc02a727cd
commit
d8f0d14005
2 files changed
+188
-2
No files matched your search
@@ -150,6 +150,20 @@ struct ATCPacket_t{ //and PVVX
|
||||
};
|
||||
};
|
||||
|
||||
// Xiaomi Mi Body Composition Scale (MIBCS/MIBFS), service data of UUID 0x181B, 13 bytes, little endian
|
||||
struct MiScalePacket_t {
|
||||
uint8_t flagsA; // bit0: unit lb, bit2: "part"/partial result
|
||||
uint8_t flagsB; // bit1: impedance stable, bit5: weight stable, bit6: unit jin, bit7: finished (user stepped off)
|
||||
uint16_t year;
|
||||
uint8_t month;
|
||||
uint8_t day;
|
||||
uint8_t hour;
|
||||
uint8_t minute;
|
||||
uint8_t second;
|
||||
uint16_t impedance; // ohms; 0xFFFE = measuring, 0xFFFD = failed
|
||||
uint16_t weight; // raw; /200 for kg, /100 for lb and jin; 0xFFF0 = overload
|
||||
} __attribute__((packed));
|
||||
|
||||
union BTHome_info_t{
|
||||
struct{
|
||||
uint8_t encrypted:1;
|
||||
@@ -281,6 +295,8 @@ struct mi_sensor_t{
|
||||
uint32_t door:1;
|
||||
uint32_t leak:1;
|
||||
uint32_t payload:1;
|
||||
uint32_t weight:1; // body composition scale
|
||||
uint32_t impedance:1; // body composition scale
|
||||
};
|
||||
uint32_t raw = 0;
|
||||
} feature;
|
||||
@@ -302,6 +318,9 @@ struct mi_sensor_t{
|
||||
uint32_t door:1;
|
||||
uint32_t leak:1;
|
||||
uint32_t payload:1;
|
||||
uint32_t weight:1; // stable weight available
|
||||
uint32_t impedance:1; // stable impedance available
|
||||
uint32_t impedanceFailed:1; // session ended without a usable impedance
|
||||
};
|
||||
uint32_t raw = 0;
|
||||
} eventType;
|
||||
@@ -329,6 +348,14 @@ struct mi_sensor_t{
|
||||
float hum;
|
||||
uint8_t hum_history[24];
|
||||
};
|
||||
struct { // MIBCS / MIBFS body composition scale
|
||||
float weight; // in the unit the scale reports (see weightUnit)
|
||||
uint16_t impedance; // ohms, 0 = not measured
|
||||
uint8_t weightUnit; // 0 = kg, 1 = lb, 2 = jin
|
||||
uint8_t scaleState; // session bookkeeping, see MI32_SCALE_* flags
|
||||
uint32_t scaleLastSeen; // uptime of last packet, for session timeout
|
||||
uint8_t weight_history[24];
|
||||
};
|
||||
struct {
|
||||
uint16_t events; //"alarms" since boot
|
||||
uint32_t NMT; // no motion time in seconds for the MJYD2S and NLIGHT
|
||||
@@ -382,8 +409,9 @@ void (*const MI32_Commands[])(void) PROGMEM = {&CmndMi32Key, &CmndMi32Name,&Cmnd
|
||||
#define YLKG08 16
|
||||
#define YLAI003 17
|
||||
#define BTHOME 18
|
||||
#define MIBCS 19
|
||||
|
||||
#define MI32_TYPES 18 //count this manually
|
||||
#define MI32_TYPES 19 //count this manually
|
||||
|
||||
const uint16_t kMI32DeviceID[MI32_TYPES]={ 0x0098, // Flora
|
||||
0x01aa, // MJ_HT_V1
|
||||
@@ -403,9 +431,19 @@ const uint16_t kMI32DeviceID[MI32_TYPES]={ 0x0098, // Flora
|
||||
0x03b6, // YLKG08 and YLKG07 - version w/wo mains
|
||||
0x07bf, // YLAI003
|
||||
0xb770, // BTHome -> fake ID
|
||||
0x181b, // MIBCS/MIBFS body composition scale -> fake ID (service UUID)
|
||||
};
|
||||
|
||||
const char kMI32DeviceType[] PROGMEM = {"Flora|MJ_HT_V1|LYWSD02|LYWSD03|CGG1|CGD1|NLIGHT|MJYD2S|YLYK01|MHOC401|MHOC303|ATC|MCCGQ02|SJWS01L|PVVX|YLKG08|YLAI003|BTHOME"};
|
||||
const char kMI32DeviceType[] PROGMEM = {"Flora|MJ_HT_V1|LYWSD02|LYWSD03|CGG1|CGD1|NLIGHT|MJYD2S|YLYK01|MHOC401|MHOC303|ATC|MCCGQ02|SJWS01L|PVVX|YLKG08|YLAI003|BTHOME|MIBCS"};
|
||||
|
||||
const char kMI32_ScaleUnit[] PROGMEM = "kg|lb|jin";
|
||||
|
||||
// scaleState flags
|
||||
#define MI32_SCALE_WEIGHT_SENT 0x01
|
||||
#define MI32_SCALE_IMPEDANCE_SENT 0x02
|
||||
#define MI32_SCALE_FAILED_SENT 0x04
|
||||
#define MI32_SCALE_FINISHED 0x08
|
||||
#define MI32_SCALE_SESSION_TIMEOUT 10 // seconds of silence that end a weighing session
|
||||
|
||||
const char kMI32_ButtonMsg[] PROGMEM = "Single|Double|Hold"; //mapping: in Tasmota: 1,2,3 ; for HomeKit and Xiaomi 0,1,2
|
||||
/*********************************************************************************************\
|
||||
|
||||
@@ -155,6 +155,9 @@ class MI32AdvCallbacks: public NimBLEScanCallbacks {
|
||||
else if(UUID==0x181a) { //ATC and PVVX - deprecated, change FW setting of these devices to BTHome V2
|
||||
MI32ParseATCPacket((char*)advertisedDevice->getServiceData(0).data(),ServiceDataLength, addr, RSSI);
|
||||
}
|
||||
else if(UUID==0x181b) { // Xiaomi Mi Body Composition Scale (MIBCS/MIBFS)
|
||||
MI32ParseMiScalePacket((char*)advertisedDevice->getServiceData(0).data(),ServiceDataLength, addr, RSSI);
|
||||
}
|
||||
else if(MI32.option.handleEveryDevice == 1) {
|
||||
MI32HandleEveryDevice(advertisedDevice, addr, RSSI);
|
||||
}
|
||||
@@ -576,6 +579,16 @@ uint32_t MIBLEgetSensorSlot(uint8_t * _MAC, uint16_t _type, uint8_t counter){
|
||||
_newSensor.feature.Btn=1;
|
||||
_newSensor.Btn=UINT8_MAX;
|
||||
break;
|
||||
case MIBCS:
|
||||
_newSensor.feature.weight=1;
|
||||
_newSensor.feature.impedance=1;
|
||||
_newSensor.weight=NAN;
|
||||
_newSensor.impedance=0;
|
||||
_newSensor.weightUnit=0;
|
||||
_newSensor.scaleState=0;
|
||||
_newSensor.scaleLastSeen=0;
|
||||
memset(_newSensor.weight_history,0,sizeof(_newSensor.weight_history));
|
||||
break;
|
||||
default:
|
||||
_newSensor.hum=NAN;
|
||||
_newSensor.feature.temp=1;
|
||||
@@ -628,6 +641,10 @@ void MI32addHistory(uint8_t history[24], int value, const uint32_t type) {
|
||||
scaled = changeIntScale((int16_t)value, 0, 1000, 1, 127);
|
||||
break;
|
||||
}
|
||||
case 4: { // weight, 0..200 kg
|
||||
scaled = changeIntScale((int16_t)value, 0, 200, 1, 127);
|
||||
break;
|
||||
}
|
||||
case 3: { // BLE sightings, count up to 127
|
||||
uint8_t sightings = history[_hour] & 0x7F;
|
||||
if (sightings < 127) {
|
||||
@@ -2188,6 +2205,93 @@ void MI32ParseATCPacket(char * _buf, uint32_t length, uint8_t addr[6], int RSSI)
|
||||
if(MI32.option.directBridgeMode == 1) MI32.mode.shallTriggerTele = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse the 13-byte body composition record broadcast by the Xiaomi Mi Body Composition Scale
|
||||
* (MIBCS, XMTZC02HM) and Mi Body Composition Scale 2 (MIBFS, XMTZC05HM) as service data of UUID 0x181B.
|
||||
* Weight is published once per weighing session as soon as the "stable" flag is set, impedance once
|
||||
* its own "stable" flag is set. A session ends with the "finished" flag (user stepped off) or after
|
||||
* MI32_SCALE_SESSION_TIMEOUT seconds of silence.
|
||||
*/
|
||||
void MI32ParseMiScalePacket(char * _buf, uint32_t length, uint8_t addr[6], int RSSI){
|
||||
if (length < sizeof(MiScalePacket_t)) return;
|
||||
MiScalePacket_t *_packet = (MiScalePacket_t*)_buf;
|
||||
uint32_t _slot = MIBLEgetSensorSlot(addr, 0x181b, 0); // fake ID, no frame counter on this device
|
||||
if(_slot==0xff) return;
|
||||
mi_sensor_t &_sensor = MIBLEsensors[_slot];
|
||||
|
||||
const bool _stable = (_packet->flagsB & 0x20) != 0;
|
||||
const bool _impStable = (_packet->flagsB & 0x02) != 0;
|
||||
const bool _finished = (_packet->flagsB & 0x80) != 0;
|
||||
const bool _overload = (_packet->weight == 0xfff0);
|
||||
const uint32_t _now = UpTime();
|
||||
|
||||
// session handling: new session on silence, or on the first non-finished packet after a finished one
|
||||
if ((_now - _sensor.scaleLastSeen) > MI32_SCALE_SESSION_TIMEOUT) {
|
||||
_sensor.scaleState = 0;
|
||||
}
|
||||
else if ((_sensor.scaleState & MI32_SCALE_FINISHED) && !_finished) {
|
||||
_sensor.scaleState = 0;
|
||||
}
|
||||
_sensor.scaleLastSeen = _now;
|
||||
_sensor.RSSI = RSSI;
|
||||
if (_overload) return;
|
||||
|
||||
// live values, always updated so the web UI can show the weight while the user is standing on the scale
|
||||
if (_packet->flagsB & 0x40) { // jin
|
||||
_sensor.weightUnit = 2;
|
||||
_sensor.weight = (float)_packet->weight / 100.0f;
|
||||
} else if (_packet->flagsA & 0x01) { // lb
|
||||
_sensor.weightUnit = 1;
|
||||
_sensor.weight = (float)_packet->weight / 100.0f;
|
||||
} else { // kg
|
||||
_sensor.weightUnit = 0;
|
||||
_sensor.weight = (float)_packet->weight / 200.0f;
|
||||
}
|
||||
const bool _impValid = _impStable && (_packet->impedance != 0xfffe) && (_packet->impedance != 0xfffd) && (_packet->impedance != 0);
|
||||
|
||||
bool _send = false;
|
||||
if (_stable && !(_sensor.scaleState & MI32_SCALE_WEIGHT_SENT)) {
|
||||
_sensor.scaleState |= MI32_SCALE_WEIGHT_SENT;
|
||||
_sensor.eventType.weight = 1;
|
||||
_send = true;
|
||||
#ifdef USE_MI_EXT_GUI
|
||||
// history is kept in kg; convert lb and jin
|
||||
float _kg = _sensor.weight;
|
||||
if (_sensor.weightUnit == 1) _kg *= 0.45359237f;
|
||||
if (_sensor.weightUnit == 2) _kg *= 0.5f;
|
||||
MI32addHistory(_sensor.weight_history, (int)(_kg + 0.5f), 4);
|
||||
#endif //USE_MI_EXT_GUI
|
||||
}
|
||||
if (_impValid && !(_sensor.scaleState & MI32_SCALE_IMPEDANCE_SENT)) {
|
||||
_sensor.impedance = _packet->impedance;
|
||||
_sensor.scaleState |= MI32_SCALE_IMPEDANCE_SENT;
|
||||
_sensor.eventType.impedance = 1;
|
||||
_sensor.eventType.weight = 1; // repeat the weight so the impedance message is self-contained
|
||||
_send = true;
|
||||
}
|
||||
if (_finished) {
|
||||
if (!(_sensor.scaleState & (MI32_SCALE_IMPEDANCE_SENT | MI32_SCALE_FAILED_SENT))) {
|
||||
_sensor.scaleState |= MI32_SCALE_FAILED_SENT;
|
||||
_sensor.eventType.impedanceFailed = 1;
|
||||
_sensor.eventType.weight = 1;
|
||||
_send = true;
|
||||
}
|
||||
_sensor.scaleState |= MI32_SCALE_FINISHED;
|
||||
}
|
||||
if (!_send) {
|
||||
#ifdef USE_MI_EXT_GUI
|
||||
bitSet(MI32.widgetSlot,_slot);
|
||||
#endif //USE_MI_EXT_GUI
|
||||
return;
|
||||
}
|
||||
// AddLog(LOG_LEVEL_DEBUG,PSTR("M32: scale slot %u weight %1_f imp %u state %02x"), _slot, &_sensor.weight, _sensor.impedance, _sensor.scaleState);
|
||||
#ifdef USE_MI_EXT_GUI
|
||||
bitSet(MI32.widgetSlot,_slot);
|
||||
#endif //USE_MI_EXT_GUI
|
||||
_sensor.shallSendMQTT = 1;
|
||||
if(MI32.option.directBridgeMode == 1) MI32.mode.shallTriggerTele = 1;
|
||||
}
|
||||
|
||||
void MI32parseCGD1Packet(char * _buf, uint32_t length, uint8_t addr[6], int RSSI){ // no MiBeacon
|
||||
uint32_t _slot = MIBLEgetSensorSlot(addr, 0x0576, 0); // This must be hard-coded, no object-id in Cleargrass-packet, we have no packet counter too
|
||||
if(_slot==0xff) return;
|
||||
@@ -2780,6 +2884,20 @@ void MI32sendWidget(uint32_t slot){
|
||||
WSContentSend_P(PSTR("<p>No leak</p>"));
|
||||
}
|
||||
}
|
||||
if(_sensor.feature.weight == 1){
|
||||
if(!isnan(_sensor.weight)){
|
||||
char _graph[256];
|
||||
char _unit[8];
|
||||
char _weightStr[16];
|
||||
GetTextIndexed(_unit, sizeof(_unit), _sensor.weightUnit, kMI32_ScaleUnit);
|
||||
ext_snprintf_P(_weightStr, sizeof(_weightStr), PSTR("%*_f"), -2, &_sensor.weight);
|
||||
MI32createGraph(_graph, _sensor.weight_history, 185, 124, 124);
|
||||
WSContentSend_P(PSTR("<p>Weight: %s %s%s</p>"), _weightStr, _unit, _graph);
|
||||
}
|
||||
if(_sensor.impedance != 0){
|
||||
WSContentSend_P(PSTR("<p>Impedance: %u Ω</p>"), _sensor.impedance);
|
||||
}
|
||||
}
|
||||
if(_sensor.feature.payload == 1){
|
||||
if(_sensor.payload != nullptr){
|
||||
char _payload[128];
|
||||
@@ -2823,6 +2941,8 @@ const char HTTP_MI32[] PROGMEM = "{s}Mi ESP32 {m} %u devices{e}";
|
||||
#ifndef USE_MI_EXT_GUI
|
||||
const char HTTP_BATTERY[] PROGMEM = "{s}%s " D_BATTERY "{m}%u %%{e}";
|
||||
const char HTTP_LASTBUTTON[] PROGMEM = "{s}%s Last Button{m}%u {e}";
|
||||
const char HTTP_MI32_WEIGHT[] PROGMEM = "{s}%s Weight{m}%*_f %s{e}";
|
||||
const char HTTP_MI32_IMPEDANCE[] PROGMEM = "{s}%s Impedance{m}%u Ω{e}";
|
||||
const char HTTP_EVENTS[] PROGMEM = "{s}%s Events{m}%u {e}";
|
||||
const char HTTP_NMT[] PROGMEM = "{s}%s No motion{m}> %u seconds{e}";
|
||||
const char HTTP_DOOR[] PROGMEM = "{s}%s Door{m}> %u open/closed{e}";
|
||||
@@ -2984,6 +3104,24 @@ void MI32Show(bool json)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (MIBLEsensors[i].feature.weight == 1){
|
||||
if(MIBLEsensors[i].eventType.weight == 1 || MI32.mode.triggeredTele == 0 || MI32.option.allwaysAggregate == 1){
|
||||
if (!isnan(MIBLEsensors[i].weight)) {
|
||||
char _unit[8];
|
||||
GetTextIndexed(_unit, sizeof(_unit), MIBLEsensors[i].weightUnit, kMI32_ScaleUnit);
|
||||
MI32ShowContinuation(&commaflg);
|
||||
ResponseAppend_P(PSTR("\"Weight\":%*_f,\"Unit\":\"%s\""), -2, &MIBLEsensors[i].weight, _unit);
|
||||
}
|
||||
}
|
||||
if(MIBLEsensors[i].eventType.impedance == 1 || ((MI32.mode.triggeredTele == 0 || MI32.option.allwaysAggregate == 1) && MIBLEsensors[i].impedance != 0)){
|
||||
MI32ShowContinuation(&commaflg);
|
||||
ResponseAppend_P(PSTR("\"Impedance\":%u"), MIBLEsensors[i].impedance);
|
||||
}
|
||||
if(MIBLEsensors[i].eventType.impedanceFailed == 1){
|
||||
MI32ShowContinuation(&commaflg);
|
||||
ResponseAppend_P(PSTR("\"ImpedanceFailed\":1"));
|
||||
}
|
||||
}
|
||||
if (MIBLEsensors[i].feature.payload == 1){
|
||||
if(MIBLEsensors[i].eventType.payload == 1 || MI32.mode.triggeredTele == 0 || MI32.option.allwaysAggregate == 1){
|
||||
if ((MIBLEsensors[i].payload != nullptr)) {
|
||||
@@ -3067,6 +3205,16 @@ void MI32Show(bool json)
|
||||
if (MIBLEsensors[i].type==YLYK01){
|
||||
WSContentSend_PD(HTTP_LASTBUTTON, _sensorName, MIBLEsensors[i].Btn);
|
||||
}
|
||||
if (MIBLEsensors[i].type==MIBCS){
|
||||
if (!isnan(MIBLEsensors[i].weight)) {
|
||||
char _unit[8];
|
||||
GetTextIndexed(_unit, sizeof(_unit), MIBLEsensors[i].weightUnit, kMI32_ScaleUnit);
|
||||
WSContentSend_PD(HTTP_MI32_WEIGHT, _sensorName, -2, &MIBLEsensors[i].weight, _unit);
|
||||
}
|
||||
if (MIBLEsensors[i].impedance != 0) {
|
||||
WSContentSend_PD(HTTP_MI32_IMPEDANCE, _sensorName, MIBLEsensors[i].impedance);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif //USE_MI_EXT_GUI
|
||||
#endif // USE_WEBSERVER
|
||||
|
||||
Reference in new issue
Block a user