Skip to content

esf37_scale.tc

esf37_scale.tc — read the Etekcity ESF37 body-composition scale over BLE (plaintext)

Source on GitHub

// 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>&#937;<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 &#937;{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);
}