Skip to content

esf37_probe.tc

esf37_probe.tc — GATT subscribe-notify probe for the Etekcity ESF37 scale

Source on GitHub

// esf37_probe.tc — GATT subscribe-notify probe for the Etekcity ESF37 scale
//
// GOAL (tests the 2026-05-29 research hypothesis):
//   Prior memory recorded "subscribe to 0x2C12 times out -> the scale is Tuya-encrypted and
//   needs a per-device localKey." Web research (hertzg/metekcity, etekcity_esf551_ble) overturns
//   that: Etekcity reuses the Tuya BLE *service UUIDs* (0x1910 + 0x2C10/0x2C11/0x2C12) but runs a
//   PLAINTEXT custom protocol on them — NO AES, NO localKey. metekcity measurement notifications
//   start with byte 0xD0. So a bare GATT subscribe to notify char 0x2C12 SHOULD stream frames with
//   zero secrets. This script finds out, and re-tests whether the old -8 timeout was just transient
//   (scale asleep / wrong moment) rather than encryption.
//
// WHAT IT DOES (read-only — writes NOTHING to the scale):
//   1. Scan; find the scale by manufacturer-id 0x06D0. The advert has NO local name and the MAC is
//      a RANDOM address that ROTATES between sessions, so we MUST discover it live each run and
//      never hardcode it.
//   2. Lock its current MAC + address-type; target the custom service 0x1910.
//   3. Repeat: bleReadStart(0x2C12) connects + subscribes the notify char + waits for one frame;
//      poll bleDone(); on >0 dump the raw bytes as hex; on <0 log the failure code and retry.
//   Step on the scale while this runs and watch the syslog.
//
// READING THE LOG:
//   - "FRAME len=.. hex=d0.."  (first byte 0xD0) => PLAINTEXT measurement. Jackpot: the localKey
//     theory is dead and Phase 3 is unblocked. Decode per metekcity:
//       sign(1: 00 pos / 01 neg) + weight(u16 BE, value/10 kg) + unit(enum) + stable(1: 01 settled).
//     (ESF37 layout still needs confirming on real bytes — this dump is exactly that evidence.)
//   - ANY frame at all (even non-0xD0) => plaintext stream confirmed; decode TBD from the bytes.
//   - Persistent bleDone()<0 with NO frame (e.g. -8 = NOTIFYTIMEOUT) => a bare subscribe is not
//     enough. The next step is a SINGLE-CONNECTION write-then-subscribe: write a metekcity start
//     cmd (SET_UNIT 0xC0 or PING 0xC3) to 0x2C11 *and* subscribe 0x2C12 on the SAME connection.
//     xdrv_79 already supports that in one op (it subscribes, then writes, then waits for notify —
//     see xdrv_79_esp32_ble.ino ~1962-2121), and tc_ble_gatt_start() already plumbs chr+wbuf+notify
//     together. The ONLY missing piece is a TinyC binding, e.g. a new bleWriteNotify(chr,buf,len,
//     notify) syscall (reserve id 422). bleReadStart + bleWriteStart can't do it because each is a
//     separate connect/disconnect and only one transaction runs at a time.
//
// SAFETY: read-only (subscribe + receive). It never writes to the scale, so it cannot change units,
//   tare, or any setting. No localKey, no pairing, no cloud.
// DEPLOY: a USE_TINYC_BLE build (env tinyc32s3-ble, e.g. .39). bleScan auto-enables BLE.
//   Read the log via UDP syslog (preferred) or the web console /cs.

char nm[40];        // advert local name scratch (ESF37 = empty)
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/read bytes (<= MAX_BLE_DATA_LEN_TC = 64)

int  phase;         // 0 = scanning for the scale, 1 = subscribe-notify probe
int  started;       // has bleScan been kicked off this scan pass?
int  inflight;      // is a GATT op currently queued/running?
int  fails;         // consecutive subscribe failures (triggers a rescan)
int  frames;        // total frames captured
int  hb;            // heartbeat tick

// Dump `n` bytes of frame[] as lowercase hex to the log.
void logHex(int n) {
  char hexbuf[140];
  int hi = 0;
  int i = 0;
  while (i < n) {
    int by = frame[i] & 0xFF;
    int n1 = (by >> 4) & 0xF;
    int n2 = by & 0xF;
    if (n1 < 10) { hexbuf[hi] = 48 + n1; } else { hexbuf[hi] = 87 + n1; }
    hi = hi + 1;
    if (n2 < 10) { hexbuf[hi] = 48 + n2; } else { hexbuf[hi] = 87 + n2; }
    hi = hi + 1;
    i = i + 1;
  }
  hexbuf[hi] = 0;
  char m[200];
  sprintf(m, "esf37: FRAME len=%d hex=%s", n, hexbuf);
  addLog(m);
}

int main() {
  phase = 0; started = 0; inflight = 0; fails = 0; frames = 0; hb = 0; ttype = 0;
  return 0;
}

void TaskLoop() {

  // ── 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 -> connect svc=0x1910",
                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; probe begins next tick
      } else {
        got = bleNext();
      }
    }

    delay(200);
    return;
  }

  // ── Phase 1: subscribe to notify char 0x2C12 and dump every frame ────────────
  if (inflight == 0) {
    int rc = bleReadStart(0x2C12);           // connect + subscribe notify; read-only, no write
    if (rc == 1) {
      inflight = 1;
    } else {
      char e[80];
      sprintf(e, "esf37: bleReadStart busy/err rc=%d", rc);
      addLog(e);
      delay(400);
    }
  } else {
    int d = bleDone();                       // 0 pending / >0 frame length / <0 failed
    if (d > 0) {
      int n = bleResult(frame);
      logHex(n);
      frames = frames + 1;
      fails = 0;
      inflight = 0;
    } else {
      if (d < 0) {
        char e[110];
        sprintf(e, "esf37: subscribe rc=%d (no frame) — step on the scale; retrying", d);
        addLog(e);
        inflight = 0;
        fails = fails + 1;
        if (fails >= 8) {
          addLog("esf37: 8 fails — rescanning (MAC may have rotated / scale 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", phase, frames);
    addLog(h);
  }
  delay(200);
}