esf37_scale.tc¶
esf37_scale.tc — read the Etekcity ESF37 body-composition scale over BLE (plaintext)
// esf37_scale.tc — read the Etekcity ESF37 body-composition scale over BLE (plaintext)
// + estimate body composition + log every weigh-in to a file.
//
// WHAT THIS IS:
// The production reader, distilled from esf37_probe.tc after the protocol was decoded live
// on 2026-05-29. The ESF37 reuses the Tuya BLE *service UUIDs* (0x1910 + 0x2C10/2C11/2C12)
// but runs a PLAINTEXT custom protocol on them — NO AES, NO localKey, NO pairing. A bare
// GATT subscribe to notify char 0x2C12 streams measurement frames. This script never writes
// to the scale (read-only) — it cannot change units, tare, or any setting.
//
// FRAME FORMAT (verified on 30+ live frames + the physical display):
// FE EF C0 A3 | TYPE(1) | LEN(1) | PAYLOAD[LEN] | CKSUM(1)
// CKSUM = (TYPE + LEN + Σpayload) & 0xFF
// TYPE 0xD0 = MEASUREMENT (LEN 8):
// [0:2] weight kg ×100, u16 BE (0x20BC = 8380 -> 83.80 kg = display 83,8)
// [2:4] weight lb ×100, u16 BE (decoded but no longer displayed — kg only)
// [4] stable flag (0x00 measuring / 0x01 locked)
// [5:8] bio-impedance, u24 BE (0x000213 = 531 Ω; only meaningful once stable)
// TYPE 0xD2 = idle heartbeat (LEN 1, payload 0x2B) — scale awake but no load. Ignored.
// Example locked frame: feefc0a3 d0 08 20bc 4830 01 000213 42 (cksum 0x42 ✓)
//
// BODY COMPOSITION (estimate — NOT the VeSync proprietary algorithm):
// From weight + impedance + a stored profile (height/age/sex) we derive, via published BIA
// equations, BMI, body-fat %, lean (fat-free) mass, body-water %, and skeletal-muscle mass.
// Impedance index II = height_cm^2 / R_ohm
// FFM (Sun 2003): male = -10.68 + 0.65·II + 0.26·W + 0.02·R
// female= -9.53 + 0.69·II + 0.17·W + 0.02·R
// Body fat % = 100·(W − FFM)/W ; Body water % = 100·(0.732·FFM)/W
// Skeletal muscle (Janssen 2000) = II·0.401 + sex·3.825 − age·0.071 + 5.102 (sex: M=1/F=0)
// BMI = W / (height_m^2)
// These are population estimates; treat them as a trend indicator, not a clinical figure.
//
// PROFILE (PERSISTENT): height/age/sex are editable in the web tile and saved to /esf37.cfg
// (CSV "height,age,sex"), so they survive a reboot. Defaults: 180 cm, 75 y, male.
//
// HISTORY LOG: every locked weigh-in (once impedance lands) appends one TAB-delimited row to
// /esf37.log: <localtime> \t kg \t ohm \t BMI \t fat% \t lean \t water% \t muscle
// The web tile renders the log as a scrollable HTML table (newest entries shown; the full
// record always lives in /esf37.log).
//
// HOW IT READS:
// xdrv_79 delivers ONE notify per subscribe then disconnects (one-shot GATT op), so we
// re-subscribe in a loop. We surface the live value on every valid 0xD0 and latch the last
// LOCKED ([4]==0x01) weight + impedance separately.
//
// DISCOVERY: the advert has NO local name; find the scale by manufacturer-id 0x06D0 (LE bytes
// D0 06). Its MAC is a RANDOM address that ROTATES between sessions — discover it live each
// run, carry the address-type into the connect, never hardcode. Step on the scale to wake it.
//
// DEPLOY: a USE_TINYC_BLE build (env tinyc32s3-ble). bleScan auto-enables BLE (no SetOption115).
// node tc_deploy.mjs examples/esf37_scale.tc <device-ip>
// Step on the scale; watch the web tile (/) or the syslog for "esf37: LOGGED ...".
int mac[8]; // 6 MAC bytes of the current advert
int mfg[40]; // manufacturer-specific data bytes (incl. 2-byte company id, little-endian)
int tmac[8]; // locked MAC of the scale (re-used as bleTarget's address buffer)
int ttype; // locked address type (random vs public) — MUST be carried into the connect
int frame[80]; // received notify bytes (<= MAX_BLE_DATA_LEN_TC = 64)
int phase; // 0 = scanning for the scale, 1 = subscribe-notify read loop
int started; // has bleScan been kicked off this scan pass?
int inflight; // is a GATT subscribe currently queued/running?
int fails; // consecutive subscribe failures (triggers a rescan)
int hb; // heartbeat tick
// decoded / shared state (read by WebCall + JsonCall)
int have; // 0 until the first valid 0xD0 frame is decoded
float w_kg; // live weight in kg
int w_stable; // 1 once the current reading has locked
int w_imp; // bio-impedance Ω (latched from the locked frame)
float lock_kg; // latched weight at the moment it locked
int nframes; // total valid measurement frames decoded
int nlock; // total locked readings captured
int logged; // 1 once the current lock has been written to /esf37.log (de-dupe)
// body-composition results (computed once weight + impedance + profile are all present)
float c_bmi; // body-mass index
float c_fat; // body fat %
float c_lean; // lean / fat-free mass kg
float c_water; // body water %
float c_muscle; // skeletal muscle mass kg
int c_have; // 1 once the impedance-derived metrics are valid
// persistent profile (editable in the web tile, saved to /esf37.cfg)
int u_height; // body height, cm
int u_age; // age, years
int u_sex; // 1 = male, 0 = female
int p_height; // shadows for change-detect -> save
int p_age;
int p_sex;
char fbuf[1400]; // scratch for reading /esf37.log back (global: char[] is int32-backed)
// Extract the idx-th comma-separated field of src into dst. Returns field length.
int csvField(char src[], int idx, char dst[]) {
int n = strlen(src);
int i = 0; int f = 0; int j = 0;
while (i < n) {
int c = src[i] & 0xFF;
if (c == 44) { // ','
if (f == idx) { dst[j] = 0; return j; }
f = f + 1; j = 0;
} else {
if (f == idx) { dst[j] = c; j = j + 1; }
}
i = i + 1;
}
if (f == idx) { dst[j] = 0; return j; }
dst[0] = 0; return 0;
}
// Load the persistent profile from /esf37.cfg ("height,age,sex"). Keeps defaults if absent.
void loadCfg() {
if (fileExists("/esf37.cfg") == 0) { return; }
int fh = fileOpen("/esf37.cfg", "r");
if (fh < 0) { return; }
char buf[48];
int n = fileRead(fh, buf, 47);
fileClose(fh);
if (n <= 0) { return; }
buf[n] = 0;
char fld[16];
if (csvField(buf, 0, fld) > 0) { u_height = atoi(fld); }
if (csvField(buf, 1, fld) > 0) { u_age = atoi(fld); }
if (csvField(buf, 2, fld) > 0) { u_sex = atoi(fld); }
char m[64];
sprintf(m, "esf37: profile loaded %d cm / %d y / sex %d", u_height, u_age, u_sex);
addLog(m);
}
// Persist the current profile to /esf37.cfg.
void saveCfg() {
char buf[32];
sprintf(buf, "%d,%d,%d", u_height, u_age, u_sex);
int fh = fileOpen("/esf37.cfg", "w");
if (fh < 0) { addLog("esf37: cfg save failed"); return; }
fileWrite(fh, buf, strlen(buf));
fileClose(fh);
char m[64];
sprintf(m, "esf37: profile saved %d cm / %d y / sex %d", u_height, u_age, u_sex);
addLog(m);
}
// Estimate body composition from weight + impedance + profile (BIA equations, see header).
void computeComp() {
if (w_imp <= 0 || w_kg <= 0.0 || u_height <= 0) { return; }
float H = u_height; // cm
float W = w_kg; // kg
float R = w_imp; // Ω
float A = u_age; // years
float S = u_sex; // 1 male / 0 female
float hm = H / 100.0;
c_bmi = W / (hm * hm);
float II = (H * H) / R; // impedance index
float ffm;
if (u_sex == 1) {
ffm = -10.68 + 0.65 * II + 0.26 * W + 0.02 * R;
} else {
ffm = -9.53 + 0.69 * II + 0.17 * W + 0.02 * R;
}
c_lean = ffm;
c_fat = 100.0 * (W - ffm) / W;
c_water = 100.0 * (0.732 * ffm) / W;
c_muscle = II * 0.401 + S * 3.825 - A * 0.071 + 5.102;
c_have = 1;
}
// Append one TAB-delimited row for the current locked weigh-in to /esf37.log.
void logMeasurement() {
char ts[40];
timeStamp(ts); // local "YYYY-MM-DDTHH:MM:SS"
char line[200];
sprintf(line, "%s\t%.2f\t%d\t%.1f\t%.1f\t%.1f\t%.1f\t%.1f\n",
ts, w_kg, w_imp, c_bmi, c_fat, c_lean, c_water, c_muscle);
int newfile = (fileExists("/esf37.log") == 0); // write the column header once, so the
int fh = fileOpen("/esf37.log", "a"); // TAB-delimited log drops straight into Excel
if (fh < 0) { addLog("esf37: log open failed"); return; }
if (newfile) {
char hdr[80];
strcpy(hdr, "Time\tWeight_kg\tImpedance_Ohm\tBMI\tFat_%\tLean_kg\tWater_%\tMuscle_kg\n");
fileWrite(fh, hdr, strlen(hdr));
}
fileWrite(fh, line, strlen(line));
fileClose(fh);
char m[64];
sprintf(m, "esf37: LOGGED %.2f kg / %d ohm", w_kg, w_imp);
addLog(m);
}
// Announce the completed weigh-in in German via the I2SAUDIO picotts plugin
// (I2Stts command). No-op on builds without the plugin — tasmCmd just gets
// "Command":"Unknown" back, harmless. Integer-part + "Komma" + tenths avoids
// relying on the engine's decimal-separator handling; ASCII-only (no umlauts)
// so it's safe through the .tc string literal + bytecode.
void speakResult() {
int kg10 = (int)(w_kg * 10.0 + 0.5); // weight to 1 decimal
int ki = kg10 / 10; int kd = kg10 % 10;
char cmd[160];
char resp[64];
if (c_have == 1) {
int ft10 = (int)(c_fat * 10.0 + 0.5); // body-fat % to 1 decimal
int fi = ft10 / 10; int fd = ft10 % 10;
sprintf(cmd, "I2Stts Sie wiegen %d Komma %d Kilogramm. Fettanteil %d Komma %d Prozent.", ki, kd, fi, fd);
} else {
sprintf(cmd, "I2Stts Sie wiegen %d Komma %d Kilogramm.", ki, kd);
}
tasmCmd(cmd, resp);
}
// Decode one measurement frame already validated as TYPE 0xD0 / LEN 8.
void decodeD0() {
int kg100 = ((frame[6] & 0xFF) << 8) | (frame[7] & 0xFF);
int stable = frame[10] & 0xFF;
w_kg = kg100 / 100.0;
w_stable = stable;
have = 1;
nframes = nframes + 1;
shareSetFloat("scale_kg", w_kg);
shareSetInt("scale_stable", stable);
// BMI needs only weight + height, so it's available even while still measuring.
if (u_height > 0) {
float hm = u_height / 100.0;
c_bmi = w_kg / (hm * hm);
}
if (stable == 1) {
// Impedance is a u24 BE; payload[5] has always been 0x00 so far (high byte / pad).
int imp = ((frame[11] & 0xFF) << 16) | ((frame[12] & 0xFF) << 8) | (frame[13] & 0xFF);
lock_kg = w_kg;
nlock = nlock + 1;
// The scale sometimes emits a stable frame a beat BEFORE the body-composition result
// lands (impedance 00 00 00). Only latch impedance when it's actually present, and never
// clobber a previously-captured value with 0 — the weight is final either way.
if (imp > 0) {
w_imp = imp;
shareSetInt("scale_imp", w_imp);
computeComp();
if (logged == 0) { logMeasurement(); speakResult(); logged = 1; } // one log row + one announcement per weigh-in
char m[110];
sprintf(m, "esf37: LOCKED %.2f kg imp=%d ohm BMI %.1f fat %.1f%% (#%d)",
w_kg, w_imp, c_bmi, c_fat, nlock);
addLog(m);
} else {
char m[80];
sprintf(m, "esf37: locked %.2f kg (impedance pending)", w_kg);
addLog(m);
}
} else {
logged = 0; // fresh measurement underway -> arm the next log
char m[80];
sprintf(m, "esf37: measuring %.2f kg", w_kg);
addLog(m);
}
}
// Validate header + length + checksum, then dispatch by TYPE.
void handleFrame(int n) {
if (n < 7) { return; } // too short for any frame
if (frame[0] != 0xFE || frame[1] != 0xEF || frame[2] != 0xC0 || frame[3] != 0xA3) {
return; // not an ESF37 frame
}
int type = frame[4] & 0xFF;
int len = frame[5] & 0xFF;
if (n < len + 7) { return; } // header4 + type + len + payload + cksum
int cs = (type + len) & 0xFF; // checksum spans TYPE..last payload
int i = 0;
while (i < len) { cs = (cs + (frame[6 + i] & 0xFF)) & 0xFF; i = i + 1; }
int cksum = frame[6 + len] & 0xFF;
if (cs != cksum) {
char e[64];
sprintf(e, "esf37: bad cksum type=%02x calc=%02x got=%02x", type, cs, cksum);
addLog(e);
return;
}
if (type == 0xD0 && len == 8) { decodeD0(); }
// type == 0xD2 (idle heartbeat) and anything else: silently ignored.
}
// Render /esf37.log as a scrollable HTML table inside the sensor tile.
void renderTable() {
if (fileExists("/esf37.log") == 0) {
webSend("<tr><td colspan=2><i>no weigh-ins logged yet</i></td></tr>");
return;
}
int fsz = fileSize("/esf37.log");
int fh = fileOpen("/esf37.log", "r");
if (fh < 0) { return; }
if (fsz > 1390) { fileSeek(fh, fsz - 1390, 0); } // tail window (the renderer drops the first line either way)
int n = fileRead(fh, fbuf, 1390);
fileClose(fh);
if (n <= 0) { webSend("<br><tr><td colspan=2><i>log empty</i></td></tr>"); return; }
fbuf[n] = 0;
webSend("<br><tr><td colspan=2><center><b>History</b></td></tr>");
webSend("<tr><td colspan=2><div style='max-height:170px;overflow:auto'>");
webSend("<table style='width:100%;font-size:75%'>");
webSend("<tr style='text-align:left'><th>Time<th>kg<th>Ω<th>BMI<th>Fat%<th>Lean<th>H2O%<th>Musc");
int i = 0;
// Always drop the window's first line: the column-header row when reading from
// the file start, or the partial row when tail-windowed.
while (i < n && fbuf[i] != 10) { i = i + 1; } i = i + 1;
char row[240];
strcpy(row, "<tr><td>");
int rl = strlen(row);
while (i < n) {
int c = fbuf[i] & 0xFF;
if (c == 9) { // tab -> next cell
row[rl] = 0; strcat(row, "<td>"); rl = strlen(row);
} else if (c == 10) { // newline -> emit row
row[rl] = 0;
if (rl > 8) { webSend(row); }
strcpy(row, "<tr><td>"); rl = strlen(row);
} else {
if (c != 13 && rl < 235) { row[rl] = c; rl = rl + 1; }
}
i = i + 1;
}
if (rl > 8) { row[rl] = 0; webSend(row); } // trailing line with no newline
webSend("</table></div></td></tr>");
}
int main() {
phase = 0; started = 0; inflight = 0; fails = 0; hb = 0; ttype = 0;
have = 0; w_kg = 0.0; w_stable = 0; w_imp = 0; lock_kg = 0.0;
nframes = 0; nlock = 0; logged = 0;
c_bmi = 0.0; c_fat = 0.0; c_lean = 0.0; c_water = 0.0; c_muscle = 0.0; c_have = 0;
u_height = 180; u_age = 75; u_sex = 1; // defaults: 180 cm, 75 y, male
loadCfg(); // override from flash if /esf37.cfg exists
p_height = u_height; p_age = u_age; p_sex = u_sex;
return 0;
}
void TaskLoop() {
// Persist profile edits made in the web form, and re-estimate with the new profile.
if (u_height != p_height || u_age != p_age || u_sex != p_sex) {
p_height = u_height; p_age = u_age; p_sex = u_sex;
saveCfg();
if (w_imp > 0 && w_kg > 0.0) { computeComp(); }
}
// ── Phase 0: scan and lock onto the scale ───────────────────────────────────
if (phase == 0) {
if (started == 0) {
bleScan(0);
started = 1;
addLog("esf37: scanning for scale (mfr 0x06D0) — step on it to wake it");
}
int got = bleNext();
while (got) {
int ml = bleMfg(mfg);
int isScale = 0;
if (ml >= 2 && mfg[0] == 0xD0 && mfg[1] == 0x06) { isScale = 1; } // company id 0x06D0, LE
if (isScale) {
bleMac(mac);
ttype = bleAddrType();
int i = 0;
while (i < 6) { tmac[i] = mac[i]; i = i + 1; }
char b[110];
sprintf(b, "esf37: SCALE found mac=%02x:%02x:%02x:%02x:%02x:%02x type=%d -> subscribe 0x2C12",
tmac[0], tmac[1], tmac[2], tmac[3], tmac[4], tmac[5], ttype);
addLog(b);
bleScanStop(); // must stop scanning before a GATT connect
bleTarget(tmac, ttype, 0x1910); // set GATT target + custom service UUID
phase = 1; inflight = 0; fails = 0;
got = 0; // stop draining; read loop begins next tick
} else {
got = bleNext();
}
}
delay(200);
return;
}
// ── Phase 1: subscribe to notify char 0x2C12, decode every frame ─────────────
if (inflight == 0) {
int rc = bleReadStart(0x2C12); // connect + subscribe notify; read-only, no write
if (rc == 1) {
inflight = 1;
} else {
delay(400); // busy/err — back off and retry
}
} else {
int d = bleDone(); // 0 pending / >0 frame length / <0 failed
if (d > 0) {
int n = bleResult(frame);
handleFrame(n);
fails = 0;
inflight = 0;
} else {
if (d < 0) {
inflight = 0;
fails = fails + 1;
if (fails >= 8) {
addLog("esf37: scale quiet — rescanning (MAC may have rotated / asleep)");
phase = 0; started = 0; fails = 0;
}
delay(300);
}
// d == 0: still pending — just keep polling
}
}
// Heartbeat so we know the script is alive while the scale sleeps.
hb = hb + 1;
if (hb >= 25) {
hb = 0;
char h[80];
sprintf(h, "esf37: alive phase=%d frames=%d locks=%d", phase, nframes, nlock);
addLog(h);
}
delay(200);
}
void WebCall() {
// Persistent body profile (editable; saved to /esf37.cfg on change). webPulldown/webNumber
// emit raw <div>s illegal inside the sensor <table>, so wrap each in <tr><td colspan=2>.
webSend("<tr><td colspan=2><b>Body profile</b></td></tr>");
webSend("<tr><td colspan=2>"); webNumber(u_height, 100, 250, "Height (cm)"); webSend("</td></tr>");
webSend("<tr><td colspan=2>"); webNumber(u_age, 1, 120, "Age (years)"); webSend("</td></tr>");
webSend("<tr><td colspan=2>"); webPulldown(u_sex, "Sex", "Female|Male"); webSend("</td></tr>");
if (have == 0) {
webSend("{s}<b>Etekcity ESF37</b>{m} step on scale{e}");
renderTable();
return;
}
char m[100];
char st[12];
if (w_stable == 1) { strcpy(st, "locked"); } else { strcpy(st, "measuring"); }
sprintf(m, "{s}<b>Etekcity ESF37</b>{m}%s{e}", st); webSend(m);
sprintf(m, "{s}Weight{m}%.2f kg{e}", w_kg); webSend(m);
if (u_height > 0) { sprintf(m, "{s}BMI{m}%.1f{e}", c_bmi); webSend(m); }
if (c_have == 1) {
sprintf(m, "{s}Body fat{m}%.1f%%{e}", c_fat); webSend(m);
sprintf(m, "{s}Lean mass{m}%.1f kg{e}", c_lean); webSend(m);
sprintf(m, "{s}Body water{m}%.1f%%{e}", c_water); webSend(m);
sprintf(m, "{s}Muscle mass{m}%.1f kg{e}", c_muscle); webSend(m);
sprintf(m, "{s}Impedance{m}%d Ω{e}", w_imp); webSend(m);
}
renderTable();
}
// JSON teleperiod — appears in the MQTT sensor payload (only once we have a reading).
void JsonCall() {
if (have == 0) { return; }
char buf[200];
sprintf(buf, ",\"ESF37\":{\"Weight\":%.2f,\"Impedance\":%d,\"Stable\":%d,\"BMI\":%.1f,\"BodyFat\":%.1f,\"LeanMass\":%.1f,\"BodyWater\":%.1f,\"Muscle\":%.1f}",
w_kg, w_imp, w_stable, c_bmi, c_fat, c_lean, c_water, c_muscle);
responseAppend(buf);
}