GitHub

Sonoff S61 (BK7238) emulation — technical reference

A working technical reference for emulating a stock Sonoff S61 plug (Beken BK7238, not the ESP32-C3 the S60 uses) in the BekenEmulator fork. It records what is known about the firmware: how it boots, how it stores its configuration, how pairing and provisioning work, how its OTA update works, and how all of that is used to convert the plug to OpenBeken and back. Each claim was tested in the emulator unless the text says otherwise; the drivers that produced the results are in tools/beken/. Per-device identifiers are kept out of this repository; examples use the made-up MAC 12:34:56:ab:cd:ef.

The dumps and plugs are the owner’s own devices. Observing, testing and decompiling a program the owner lawfully owns, so other software can interoperate with it, rests on the interoperability and study exceptions — EU Software Directive 2009/24/EC, Arts. 5–6, and UK CDPA 1988, ss. 50A/50BA/50B; right-to-repair law is background support, not the grant. This is the same footing for any device the project adds. See legal.md.

Goals

  1. Convert the plug to OpenBeken (or ESPHome) over OTA, and back to stock — as the S60 is converted to Tasmota/OpenBeken/ESPHome, through the vendor’s own update path, with no soldering. See Firmware update and conversion.
  2. Read the stored Wi-Fi credentials from flash, so the conversion retains the user’s network with no re-typing — as the S60 conversion does. They are stored plaintext in the EasyFlash V4 store under wifi-ssid/wifi-pass, readable from a dump; see Wi-Fi credentials.
  3. Run the stock plug in the emulator, onto Wi-Fi and the cloud — to study the firmware without a plug on the bench. Seeding credentials joins it to an AP, and synthesising the pairing-success trigger dials the eWeLink cloud and reaches ONLINE against a local stub (own account and plug only; --net-out for the real cloud). See the cloud dial.

The emulator

Run the S61 dump:

tools/beken/emulator/bk-emu.sh run captures/<S61 dump>.bin --wifi-peer auto
# which is, in the container:
python /opt/beken-emulator/src/main.py <S61 dump>.bin --with-boot -chip BK7238 --only-uart --wifi-peer auto

Always pass --only-uart. Without it the emulator logs every MMIO access (300k+ lines) and runs so slowly it never progresses past init.

Boot

Stock boot once looped on a watchdog reset. Cause: right after ais_evt_task starts, the Beken SDK reads the flash chip’s Unique ID (command 0x4B, 4 dummy bytes, 8 data bytes) over the SPI master at 0x802700 — a path separate from the memory-mapped flash the emulator serves. That controller was unmodelled, so its STATUS register never signalled “done”, the read spun, and the task watchdog reset the chip.

Fixed in the fork by src/spi_flash.py (SpiFlashMaster): it serves the SPI STATUS/DATA registers and answers 0x4B with a synthetic unique id, plus ~3 lines wiring it into the two MMIO hooks in src/emulator.py. It engages only while a transfer is configured (CONFIG != 0), so firmwares that never drive the SPI master are unaffected. Result: 0 reboots, RTOS runs, 6/6 regression (SPI-LED controllers, OpenBK7238 Wi-Fi/DHCP/HTTP, the S61 dump test).

What the firmware does at boot

The eWeLink app runs. It boots to ate_mode_state[0] (normal, not ATE), runs its ais event loop, drives its status LED through the PWM block (0x802B00, ~290k writes/run), brings up its BLE stack (BLE_STACK_OK), and initialises EasyFlash V4.1.99. It then settles into a quiescent idle: all AIS tasks created and blocked on their event queues, timers and interrupts (SYS/IRQ/FIQ/SVC) firing, ~58k idle-loop hits, zero Wi-Fi frames transmitted. The central event loop is a message pump at 0x23d6c blocked on a queue receive (0x134f7c) from the queue whose handle is *(0x403c0c)+4 = 0x403c10 (created ~0.45 s into boot by 0x17700, stored at 0x16604); it dispatches event messages 1–9 and otherwise waits.

The device is unprovisioned: its EF40 store has no wifi-ssid/wifi-pass, and its CHIP ENV has no Matter fabric. So it does not try to connect — it waits for a pairing trigger (the button, or a command from the app over BLE/cloud). The 0x7fffffde RX filter and the “monitor mode … as in EZ pairing” UART line are the MAC’s default filter printed during rwnxl_init; they are not an active AirKiss sniff (the monitor callback 0x403b20 is NULL at boot — and in any case this build only ever registers a stub there, see AirKiss). BLE does not advertise at boot either (0 advertising payloads staged). This is a plug sitting powered-on-but-untouched.

Flash layout

Code regions are stored CRC-striped: 32 data bytes then a 2-byte CRC, repeating. Un-stripe before searching code for strings or RBL headers. The config regions are not striped — the firmware reads them as raw physical bytes through the flash controller.

Partition Offset Size Notes
bootloader 0x000000 0x010000 RT-Thread + FAL + rt_ota (V0.2.8-beken)
app 0x010000 0x250000 the running firmware
download 0x286000 0x15A000 staged OTA slot (erased in this dump)
factory TLV 0x3e0000 (0x1e0 used) manufacturing store — MAC + radio calibration
EF40 app 0x3e2000 0x9000 EasyFlash V4 KV store — device config + creds (active write sector 0x3e3000)
EF40 sys 0x3f6000 0x8000 EasyFlash V4 CHIP/ENV store

The three config regions are read raw (un-striped). The OTA app conversion (download→app) never touches any of them, so everything below survives a conversion to OpenBeken — the basis for both Wi-Fi handover and reversibility.

0x3e0000 — factory TLV store (MAC + radio calibration)

A nested TLV: "TLV\0", total-len u32, then sections. Each section is a header tag=0x00 | marker(3) | payload_len u32, then entries tag(1) | marker(3) | len u32 | value. Two sections on this dump:

0x3e2000 — EF40 app KV store (device config + Wi-Fi creds)

EasyFlash V4; sector magic "EF40", nodes are KV40 records (format below). The keys present on this (paired-then-reset, join-unprovisioned) dump — values of per-device secrets redacted:

Key Size What it is Useful to OpenBeken?
elecCalibCoef 80 B factory energy-meter calibration (binary) yes — accurate V/A/W out of the box
elecCalibFlag 1 B metering calibrated (01) yes
outlet&conf JSON power-on/startup state, pulse, offBrightness yes (relay boot behaviour)
sled&conf JSON status-LED behaviour (sledOnline, relayLight) yes
elec&app / elec&appex JSON over-power / -voltage / -current protection yes (protection limits)
elec_region, elec_dst_hour 1/20 B meter region + DST maybe
wifi-ssid / wifi-pass ≤32/≤64 B plaintext Wi-Fi creds (provisioned plugs only; absent on this dump) yes — the handover
ssid / pass 34/66 B obfuscated (NOT plaintext; different encoding — undecoded) unknown
apiKey, app-apikey, domain, secureCertSha256, factory, user — eWeLink cloud identity/certs (per-device; redacted) no (cloud is dropped)
timerInfo, timer&revision, outlet&protect, lan&info, ota&*, recordInfo, fabric&list, RecoverFlag, fc_rssi, port — timers / misc eWeLink state mostly no

0x3f6000 — EF40 system/CHIP ENV

Boot/reboot counters (chip-counters.*), chip-factory.uniqueId, and Matter globals (g/*). No f/1/n fabric/NOC on this dump.

EF40 enumeration tool: tools/beken/ef40_dump.py <flash.bin> lists every KV40 node with CRC validation.

Update (RBL) format

Images the bootloader applies are rt_ota RBL containers. The 96-byte header is verified byte-for-byte against the dump (tools/beken/rbl.py reproduces the real bootloader and app RBL headers exactly, and the body of every image the SDK’s packer made - the stock app’s, OpenBeken’s, LibreTiny’s):

offset field notes
0x00 magic "RBL\0"
0x04 algo u32: crypt nibble (checked as a flag — must be non-zero) + compression nibble (LZMA-family; e.g. app = 0x402)
0x08 timestamp
0x0c name[16] "app", "bootloader"
0x1c version[24] e.g. "1.0.2"
0x34 sn[24]
0x4c body CRC32 standard CRC-32 of the packaged (compressed) body
0x50 raw hash FNV-1a-32 of the raw (decompressed) body
0x54 size_raw decompressed size
0x58 size_package packaged size (< size_raw when compressed)
0x5c header CRC32 standard zlib CRC32 over bytes [0:0x5c]

The two CRC fields (body CRC32, header CRC32) are the standard CRC-32 (reflected, polynomial 0xedb88320 — the zlib/PNG one; the bootloader’s table generator at 0x9674 builds exactly that from a 14-tap array), so they are zlib.crc32, reproducible offline. The raw hash field is FNV-1a-32 (offset basis 0x811c9dc5, prime 0x01000193) over the decompressed image — confirmed byte- exact against the stock app’s stored value (0x0d89f7b1). None of these is a proprietary “on-chip” hash.

The bootloader copies download → app, decompressing and validating CRCs. A third-party image is accepted: the crypt nibble need only be non-zero (a flag), and rt_ota_custom_verify is a link-address sanity check, not a signature — see Firmware update and conversion.

Wi-Fi credential storage

On a provisioned plug the Wi-Fi credentials are stored in the clear as EF40 KV nodes — no cipher, no transform. Reading them needs no decoder, no pairing and no emulator: parse the KV store from a flash dump.

EF40 KV node format (verified on real nodes):

"KV40"(4) | len(4) | crc32(4) | name_len(1)+0xff*3 | value_len(4) | name | value | 0x00 0x00 + 0xff*6
  len = 20 + name_len + value_len + 8
  crc = zlib.crc32( bytes from the name_len field through the end of value )   # standard crc32

Because the region is raw (un-striped), nodes can be written in place: craft valid wifi-ssid/wifi-pass nodes and append them to the write sector (0x3e3000). This is a working, reusable synthetic-provisioning technique (both re-parse with correct CRC). It writes the creds but does not by itself make the emulated device connect — connect is gated on the commissioned/bound state, not on cred presence (see The provisioned state).

The one derived value is the apikey: a UUID-format apikey appears in RAM at boot with no copy in flash — the writer at 0x427b8 formats it character-by-character from a device MAC+serial buffer (memset 0x45ec). This is device identity, unrelated to the plaintext Wi-Fi creds.

S60 vs S61 storage

Both devices store Wi-Fi credentials in the clear; only the container differs, so the conversion approach transfers even though the parser does not:

The conversion model is identical: read the stock store, pull the plaintext SSID/password, re-apply after flashing Tasmota/ESPHome.

Cloud protocol and trust

The provisioned state

“Provisioned/commissioned” on this firmware is a cryptographic trust relationship with an external authority, not a flag plus credentials. The firmware carries a full Matter commissioning stack — NOC (node operational certificates), fabric (110 refs), PASE (SPAKE2+ passcode handshake), CASE (certificate-authenticated sessions), device attestation, setup-passcode — plus eWeLink cloud binding (apikey / deviceid / device_secret). A provisioned device holds a valid fabric (NOC/ICAC chain + operational keys established over CASE) and a cloud binding issued by eWeLink.

The public dump carries only a cloud identity (apiKey, app-apikey, secureCertSha256); it has no Wi-Fi creds and no Matter fabric. The provisioned-vs-idle decision is 0x48cc6 (reads commission_status / recover_flag; status in [sp+0xc] = 0/1/2); it is an event-triggered callback (pointer loaded ~0x54250). A stock unprovisioned plug does not auto-reconnect: with no Wi-Fi creds and no fabric it stays idle, waiting for a pairing trigger. On a provisioned plug the app reads wifi-ssid/wifi-pass through ef_get_env_blob (0x12f438) about 3.1 s into boot and the station connect follows — provided the app task is past the integrity gate, which otherwise hangs it before the credential read.

A fabric and cloud binding are credentials signed/issued by the commissioner’s / cloud’s private keys; synthesising them locally has not been achieved, because forged values do not pass the firmware’s cert/signature checks. The practical way to a genuinely cloud-provisioned image is to pair a real plug with the eWeLink app, then dump it. (This concerns a bound session a server will accept. The cloud dial itself — GET /dispatch/device — needs none of it: it is reproduced by synthesising the pairing-completion trigger, and the connect machinery it starts can be driven without a live commissioning session; see The cloud dial, achieved.)

Seeding credentials joins the plug

Seeding the stored Wi-Fi credentials puts the emulated plug on a network for real — the same end state a real provisioning reaches, without the BLE handoff the radio-less emulator cannot do. seed_join.py hooks the firmware’s own ef_get_env_blob (0x12f438) to return a chosen SSID/passphrase for wifi-ssid/wifi-pass, and attaches the fork peer as a WPA2-PSK AP with the same creds. The plug then runs the genuine sequence: station mode → authenticate → associate → WPA2 four-way handshake → IPv4 DHCP → 192.168.1.100. The Matter BekenWiFiDriver connect path runs the same way.

Left to itself after the join, the plug does Matter work — it advertises _matterc._udp (Matter commissionable) over mDNS and reads the Matter fabric/attribute KVS (g/fidx, g/fs/c, g/a/...). It does not spontaneously dial the eWeLink cloud: the cloud link is created on demand only when the device becomes eWeLink-paired, so seeding creds reaches the network but not that state. That pairing-completion is a single firmware event, however, and synthesising it makes the plug dial the eWeLink cloud for real — see The cloud dial, achieved. The machinery below is what that trigger drives.

Traced from the app layer to its root, the eWeLink cloud is a set of AIS tasks created on demand by ais_appfw:

task entry role
ais_appfw_task 0x319a8 app-framework state machine (comes up even unbound, reads wifi-ssid, then waits)
ais_link_task 0x35aa8 cloud link: dispatch (/dispatch/device) → IoT WebSocket (/api/ws)
ais_ws_task create near 0x385a4 WebSocket pump
ais_httpd_task — LAN control server (:8081), online only

ais_link_task is a context-driven state machine (reads the global context *0x4044ac, state byte at ctx+0x290); it is created (via the RTOS create 0x1349f0, name+entry literals at 0x35820/0x35824) by ais_cloud_start (0x35744) when the appfw reaches the eWeLink-paired state. Its context does not have to be rebuilt by pairing — it is already populated at boot (device UUID, model, MAC, a factory dispatch URL; see The cloud dial, achieved) — so the only thing to supply is the pairing-completion trigger, which the pairing state machine fires on success and which can be synthesised with a single call. The app’s own provisioning input is the pairingMode command (0x492ae): a JSON-array command (cJSON 0x264d6/0x265da/0x26712) carried in a session object (session+0x8c = JSON ptr, +0x90 = length, +0x88 = flag), with per-item fields base64/hex-decoded (0x12f8b8) and run through crypto/list operations (0x5b078, 0x4842e/0x48372/0x48400). No eWeLink binding key is read at boot, but none is needed for the dial — the dispatch context is factory-resident.

Assessment. Reconstructing eWeLink’s full encoded provisioning command — the JSON-array schema plus the base64/hex + crypto the app uses over the session — and feeding it to 0x492ae with a fabricated session would be a sizeable sub-project. But that is not needed to reach the cloud: the only thing the full pairing produces that the dial depends on is the completion trigger, and that is one firmware call. Synthesising it (plus seeding Wi-Fi) reaches the eWeLink cloud dial directly (cloud_dial.py), with no protocol reconstruction and no bound dump. A genuinely eWeLink-bound dump is still useful — for a faithful dispatch server (the factory URL is a placeholder) and a real device-secret — but is no longer required to reproduce the dial. Getting onto the network is done and faithful either way.

The provisioning input is the eWeLink BLE provisioning protocol, now reverse-engineered end to end. A BLE write is routed by a dispatcher (0x72940): it looks the message type up in a table at 0x145430 and posts {index, payload, len} to a queue (0x134b6c → queue *0x402dd0); a consumer task then calls table[index].handler(payload, len). The six commands (named by their [BLE CMD: …] log strings):

type handler command purpose
0x02 0x725e4 AUTH_TOKEN app proves it knows the device apikey
0x04 0x727bc REQ_SCAN / REQ_AUTH scan APs; sets state byte 0x40cf14=2
0x07 0x722a4 REQ_CONNECT connect to a scanned AP (matches BSSID)
0x09 0x72258 DEVICE_INFO_REQUEST device returns its info; state =5
0x0b 0x718a0 SERVER_INFO_REQUEST cloud/dispatch server
0x0d 0x712e0 HIDDEN_AP_CONNECT Wi-Fi creds (recv_ssid)

Crypto, solved. Each data-carrying message is base64-decoded then AES-128-CBC + PKCS#7 decrypted. The AES key and the AUTH_TOKEN “cache” are the same 16-byte secret at RAM 0x40ce90 (derived from factory_apikey at boot; a 16-byte value, with the apikey UUID in the same page — real device identifiers, not reproduced in this document). So the shared secret is readable from the emulator — no forging. AUTH_TOKEN (0x725e4) memcmps the decrypted token against that cache (0x13456c at 0x726d0; error auth_token cmp err, recv[%s] cache[%s]); in the emulator that compare can be read-and-matched or forced equal.

The Wi-Fi message (type 0x0d) is reconstructed and DRIVEN to a real join. recv_ssid(r0=packed_buf, r1=tag), buf = [ssid_len:1][ssid][pad:1][timeout:1][encflag:1][password…]; encflag=1 takes the path that copies the password into the connect config (the encflag=0 plaintext path copies only the ssid — a firmware quirk). recv_ssid packs a config (method=1 station, ssid@+1, pass@+0x22, bssid@+0xcf) and dispatches it through the method dispatcher 0x21eb4. Driving it in the appfw context (ARM force-call) with the password decoder bypassed, the plug associates (WPA2) and gets DHCP 192.168.1.100 — the eWeLink provisioning path, end to end (a scratch driver, since retired; archive/s61-research/ble_provision.py drives the same handlers through the dispatcher).

What this reaches, and what it does not. Driving these messages through the dispatcher runs the handlers and connects Wi-Fi (ble_provision.py), but does not fire the pairing-completion event: the handlers share a session state machine (session flag 0x40cfe3, step byte 0x40cf14) and a field accumulator, and the completion is normally posted by the BLE-connection lifecycle, which the radio-less emulator does not produce. So after the full message sequence the plug is on Wi-Fi but still unpaired — it keeps emitting the 0xFFF6 eWeLink pairing advertisement — and dials nothing. That completion event is, however, a single firmware call; synthesising that (rather than the entire BLE session) is what reaches the cloud, as the next section shows.

The cloud dial: synthesise the pairing-success trigger

The emulated plug now performs the real eWeLink (CoolKit) cloud handshake. Driven end to end by tools/beken/cloud_dial.py: seed Wi-Fi creds (join + DHCP), then force-call the firmware’s own start-cloud handler once Wi-Fi is up. The plug opens a TCP connection to its dispatch server and sends the CoolKit GET /dispatch/device request, retried with backoff — the genuine first step of eWeLink cloud registration. The target is read from the AIS context and is steerable. The tool defaults to a local stub on the emulator’s own AP gateway (192.168.1.1:80), so the dial stays on this host and reaches nothing outside it; the firmware’s factory placeholder (8.8.8.8:80) is used only if you pass DISPATCH= empty. Owner’s own account only: never steer this at the real eWeLink cloud with anyone’s account or plug but your own.

The cloud-dial gate chain. ais_link_task (0x35aa8) is created by ais_cloud_start (0x35744) via the RTOS thread-create 0x1349f0 (name/entry literals 0x35820/0x35824 → entry 0x35aa9). ais_cloud_start has one call site, the start-cloud handler 0x2c204:

0x2c204  if (*0x403e31 != 0 && precond_0x24fd8() == 0)   // precond returns ctx[0x31]
             ais_cloud_start(0x35744)
                 ctx = *0x4044ac;  if (ctx && ctx[0xaeb] == 0)  create ais_link_task

The start-cloud handler has exactly three callers — 0x48ffe, 0x49b76, 0x49ebc — all inside the eWeLink pairing state machine (0x48a84..0x49cxx, the PAIR_EVT_* / ewelink pair ok code). On hardware the dial is kicked off by pairing success; force-calling 0x2c204 substitutes for that one event.

The AIS context is pre-populated at boot. The cloud context *0x4044ac (heap, e.g. 0x42c870) already holds everything the link task needs, with no pairing:

offset field value in the public dump
+0x0b device UUID the device’s apikey UUID (redacted)
+0x30 model SN-BK7238-S61-01
+0x51 MAC the device MAC (redacted)
+0x5f dispatch URL http://8.8.8.8:80 (factory placeholder)
+0x31 precond byte 0 (gate passes)

(The UUID and MAC are real per-device identifiers; they are read from the dump at runtime but kept out of this public repo — see the private captures/ for a device’s own values.)

So the link task is not “spawned cold” — its context is ready at boot; only the trigger was missing. The factory dispatch URL being a placeholder (not a real CoolKit host) is the one reason a bound dump still helps — for a faithful server and real device-secret — but the dial mechanism itself is fully reproduced in the emulator. Overwriting ctx+0x5f points the dial at any server; a local stub then completes the exchange (dispatch → /api/ws, with the S60 trust-swap for TLS).

Verified (python3 cloud_dial.py, the default local stub at 192.168.1.1:80):

device IP = 192.168.1.100
dial-chain: {'ais_cloud_start': 1, 'link_create': 1, 'ais_link_task': 1}
TCP 192.168.1.100:62636 <-> 192.168.1.1:80 sends: HTTP GET /dispatch/device HTTP/1.1
  (retried t=34.6, 35.7, 37.2, 39.3, 41.9, 45.0, 48.6 — backoff)

This is the full provisioning outcome on the radio-less emulator: the plug joins the emulator’s AP and dials the eWeLink cloud. The only piece not reproduced is the BLE GATT link that, on hardware, carries the pairing that naturally fires this trigger; cloud_dial.py synthesises that single completion event instead.

The cloud handshake: trust-swap to ONLINE

With the dial reproduced, the rest of the eWeLink handshake completes against a local stub and the plug reaches ONLINE — driven end to end by tools/beken/cloud_online.py and the CoolKit stub (tools/espressif/emulator/stub_cloud/stub_server.py, extended with a --plain dispatch mode). Nothing leaves the host: the netstack relay (attach_wifi_peer(net_out=True)) is redirected so the factory dispatch IP resolves to loopback — plaintext dispatch on one port, TLS IoT on another.

The dispatch reply the S61 parser needs (differs from the S60). The embedded cJSON parser in the dispatch handler (0x34ca0; parse 0x264d6, getters 0x2669c/0x265e2) is strict in three ways the S60 is not, and each silently makes the plug re-dispatch until fixed:

Hand back {"error":0,"reason":"","IP":...,"domain":...,"port":"443"} and the plug accepts dispatch and opens the IoT WebSocket.

The IoT channel is wss:// and the cert is verified (authmode REQUIRED), so a self-signed stub cert is rejected with bad_certificate — the same trust problem the S60 solves with patch_stock --trust-ca, fixed the same way: a trust-anchor swap, not a verify bypass. The baked-in anchor is CN=CoolKit Root CA ECC, O=CoolKit, C=CN (an EC CA, 510-byte DER). Unlike the S60, the S61 keeps no static CA in its image — it assembles the CA into RAM at TLS-setup time (via snprintf, around 0x3c0b6) and installs it through mbedtls_x509_crt_parse (0x125e4c; call sites 0x3c0ca/0x3c5b8, both reached only for the IoT client). So the swap is applied at that one point: when the firmware loads the CoolKit CA into a trust store, cloud_online.py substitutes its own EC P-256 CA, identified by the CoolKit CN inside the decoded DER so only the anchor is touched. Every check in the verify path then runs unmodified; only which root is trusted changes, and the stub serves a leaf signed by that CA.

Two leaf details the firmware’s mbedTLS requires. The stub leaf must carry a dNSName SAN for the dialled address — an iPAddress SAN alone is not matched against the name the firmware verifies (it sends SNI 8.8.8.8) — and a serverAuth EKU. With those the verification passes.

Verified (tools/beken/emulator/bk-emu.sh python tools/beken/cloud_online.py):

dispatch -> {"error":0,...,"domain":"8.8.8.8","port":"443"}   (accepted)
CA trust-swaps = 1   (CoolKit root CA -> the stub's own CA)
websocket open                                 (TLS handshake against the stub's CA)
ws <- {"action":"register","version":8,"romVersion":"1.0.2",...}
ws -> {"error":0,...,"config":{"hb":1,"hbInterval":10}}        => ONLINE
ws <- {"action":"query",...,"params":["TZ"]}   (heartbeat / timezone loop, reconnects)

So the radio-less emulator now runs the whole eWeLink cloud sequence — dispatch → TLS IoT WebSocket (trusting that CA) → register → ONLINE, then heartbeats. The one thing a stub cannot stand in for is a bound session the real cloud would accept (that needs eWeLink’s device-secret, from a paired dump); with the trust swap, a local stub completes the exchange in the emulator.

Firmware update and conversion (the OTA path)

The goal is the S60-style conversion to open firmware — here OpenBeken / ESPHome for the BK7238 — driven through the vendor’s own OTA, as the S60 is through the CoolKit cloud. The headline: a third-party, unsigned image is accepted and written to the app partition — there is no signature and no secret key anywhere in the OTA path. Images are packed with AES-256 under RT-Thread’s public default key, which the bootloader has compiled in, so the encryption protects nothing; the integrity checks are standard CRC-32 and an FNV-1a hash; and the RBL crypt field is a header flag that need only be non-zero.

Server redirect and OTA initiation

The S61 uses the same CoolKit OTA mechanism as the S60: the cloud pushes an {"action":"upgrade","params":{"model","version","binList":[{"downloadUrl", "digest","name"}]}} message over the IoT WebSocket; the device’s OTA task (ais_qota_task, user-agent FOTA/1.0) downloads from whatever downloadUrl the command carries, over HTTP with a ranged bytes=%lu-%lu request, independent of the WebSocket session. There is no separate update-server setting — the command dictates the URL, exactly as tools/ewelink/probe_ota_command.py relies on for the S60.

Reproduced on the emulator: with the plug ONLINE on the local stub, pushing the upgrade command (the stub holds the device’s socket, so it sends the frame directly) makes the plug issue GET <downloadUrl> with FOTA/1.0. Served a 404, it fetches without flashing — the safe-probe pattern. So whoever runs the stub can both initiate the update and point the server at one they control.

The CoolKit “FOTA” download container

The OTA download is not a raw RBL — ais_qota_task expects a CoolKit FOTA container, decoded from the device’s own read offsets and parse code:

The payload’s CRC is checked only once the whole payload is in (0x397ce), and the payload is staged as it arrives, so a wrong one still leaves the image in the download partition - but the updater then reports {"ota":"fail","ota_fail_reason":1} and returns without restarting. Only with it right does the task write ota&state 4 and post the OTA event (id 1) whose handler (0x2c738) waits 1 s and calls bk_reboot(1), so the bootloader installs the update straight away; with it wrong, the plug converts only when it is next switched on.

The CRCs are standard CRC-32 (zlib poly), stored big-endian because the firmware byte-swaps the field before comparing. With this container the plug (ONLINE on the local stub) downloads via ranged GETs and writes the payload verbatim to the download partition — confirmed byte-for-byte (an 8 KB test RBL staged with a matching SHA-256; and a 507 KB real OpenBeken .rbl staged byte-exact over the first 446 KB before the test’s time budget ended the download).

Bypassing FOTA: stage directly, and the bootloader gate chain

The FOTA container is only the cloud transport. The question “will the device accept and boot a third-party image?” is answered by the bootloader, so the quickest test stages an rt_ota RBL directly in the download partition and boots, skipping the container.

Flash geography (physical offsets, found by logging the reads the bootloader issues through the flash controller at REG_FLASH 0x803000 during boot):

On boot the bootloader runs ota_main → rt_ota_check_upgrade → rt_ota_upgrade → copy_fw_from_dl_part. (It lives in the bootloader partition 0x0–0x10000; its message strings are loaded with adr, PC-relative, so a data-word xref does not find them, but the code is at offset-equals-runtime and the __func__ pointers are ordinary literals.) Three gates, in order:

Gate 1 — the crypt nibble. ota_main reads the RBL algorithm’s crypt nibble and, if it is zero, logs none encryption Not allow! (ota_main:125) and refuses the update, so it must be non-zero. The only key in play is RT-Thread’s public default AES key (0123…), the one the SDK’s packer uses — no per-device or secret key. The stock app declares 0x402 and OpenBeken’s release RBLs 0x102; both satisfy the gate. (The emulator also offers an opt-in to force the branch.)

Gate 2 — rt_ota_custom_verify is a link-address sanity check, not a signature. With a mis-linked image it logs The application must link to the flash 64KB location! (rt_ota_custom_verify): it CRC16s the vector table and checks that an address field in the image lies within the app partition range [0x10000, 0x10000+size) — i.e. the firmware must be linked to run at 0x10000. A real OpenBeken / ESPHome BK7238 build (linked there) passes it; only a random test body fails. No cryptographic signature is enforced anywhere in the apply path.

Gate 3 — the firmware CRC-32. copy_fw_from_dl_part verifies the body against the RBL header’s CRC fields using standard CRC-32 (polynomial 0xedb88320; body CRC32 over the packaged body, raw hash over the decompressed image). These are plain zlib.crc32, computed offline when building the image — no oracle needed.

Result: the bootloader erases and writes a third-party image

With gate 1’s flag set, gate 2 satisfied (or bypassed for a dummy body — it gates on no secret), and gate 3’s CRC supplied, the bootloader logs the full apply:

OTA firmware(app) upgrade(1.0.2->9.9.9) startup
The partition 'app' is erasing  →  erase success
OTA Write: [app] [====================================] 100%

It erases the app partition and writes the unsigned, third-party image to it. So the conversion is feasible at the firmware level: no signature, no encryption, no per-device binding is needed to have an image accepted and flashed.

Emulator support for the in-emulator apply + boot

Performing the apply in the emulator needs two pieces of flash hardware the bootloader relies on only when reprogramming its own flash; both are now modelled (behind a flag enabled for self-reprogramming emulation — see The emulator performs the whole OTA apply):

Neither bears on real hardware, where the write and read-back are consistent.

The apply path

copy_fw_from_dl_part reads the staged RBL body from the download partition (0x7654/0x7218: bounds-check, then the flash device’s read method), applies the declared crypt and compression, verifies it against the header, and writes it to the app partition. What matters for a conversion:

The download path writes bytes verbatim

Does ais_qota_task transform the bytes of the cloud download before storing them? It does not. The download loop issues ranged GETs, copies each chunk into a RAM buffer, and calls the image type’s write callback, which lands in the FAL partition write (0x1349f0); the per-image validate handler the stock type registers is literally movs r0, #0; bx lr (a no-op success). The only checks are integrity: the FOTA magic, the trailer and descriptor CRC-32s, the version-greater-than comparison, and the digest (a SHA-256 of the file, from the upgrade command — which whoever pushes the command computes over whatever they serve). Bytes are written to the download partition verbatim.

Proven: a real OpenBeken image converts over OTA

The compression need not be reverse-engineered, because OpenBeken already ships BK7238 OTA images in this exact rt_ota format — the OpenBK7238_<ver>.rbl assets on the OpenBK7231T_App releases. The 1.18.319 image is a well-formed RBL: magic RBL\0, name app, algo = 0x102 (crypt flag 2, compression nibble 1 — the stock app uses 4), body CRC32 a standard zlib.crc32, raw hash an FNV-1a, size_raw 750544, size_package 506976.

Staged directly in the download partition of a dump copy and booted on the stock bootloader with no bypasses, it passes every firmware gate:

OTA firmware(app) upgrade(1.0.2->1.18.319) startup
total_len(750544) >= size_raw(750544), _STREAM_END     ← decompresses (compress=1 supported)
The partition 'app' erase success
OTA Write: [====================================] 100%  ← full image written

So algorithm support, the crypt flag, body CRC32, decompression of the whole image, the streaming FNV raw hash, custom_verify (OpenBeken links at 0x10000), and the final part_fw_hash_verify all pass for a genuine third-party conversion image, and the bootloader then boots the freshly-installed OpenBeken — the emulator performs the whole apply itself (see below).

End to end, then: push upgrade with a downloadUrl you control → the plug downloads the FOTA-wrapped OpenBeken .rbl and stages it verbatim to the download partition (confirmed byte-for-byte for the full 507 KB image) → on reboot the stock bootloader decompresses and applies it. Nothing in the chain decrypts, signs, or needs a secret. fota_wrap.py builds the container for any .rbl. The remaining step — flashing a real plug — is irreversible and is not part of this emulator work.

Booting the converted firmware in the emulator

The emulator runs OpenBeken. Booting OpenBK7238_QIO_<ver>.bin (OpenBeken’s full flash) comes up to its application layer — OpenBK7238, version 1.18.319, config load, MQTT registration, and the config SoftAP. And the conversion itself boots: splicing OpenBeken’s app (from the QIO, CRC-striped) over the stock app partition while keeping the stock bootloader and booting it gives

RT-Thread OTA package(V0.2.8-beken-...) init success   ← the stock S61 bootloader
go os_addr(0x10000)                                     ← jumps to the app
SDK Rev: 3.0.78 / OSK Rev: F-3.0.51                     ← OpenBeken's build (stock is 3.0.70)
OpenBK7238, version 1.18.319
Main_Init_After_Delay done
softap ssid=OpenBK7238_<id> channel=1 cipher=OPEN       ← OpenBeken's config AP is up

(The <id> OpenBeken appends is derived from the device identity in the dump and is redacted here, like every other per-device value in these notes.)

So the stock bootloader boots the OpenBeken app end to end (this can also be shown directly by splicing OpenBeken’s app over the stock app partition and booting).

The emulator performs the whole OTA apply

The emulator reproduces the full apply itself — staged OpenBeken RBL in the download partition → erase → decompress → program → part_fw_hash_verify → jump → OpenBeken boots — once it models two pieces of flash hardware the bootloader relies on only when reprogramming its own flash:

Both are gated behind an emulator flag enabled only when emulating self-reprogramming firmware (modelling real flash writes is correct but makes flash-writing firmware run slower), so normal device emulation is unaffected. With it on, the apply completes and OpenBeken boots exactly as above; the written app de-stripes byte-for-byte to the OpenBeken image and its FNV equals the RBL raw hash.

Carrying Wi-Fi across the conversion: the OpenBeken config

Like the S60, the conversion should hand the user’s Wi-Fi credentials to the new firmware so the plug rejoins their network with no typing. The stock creds live in the EF40 store (above), which the OTA does not overwrite. Two ways to get them into OpenBeken were worked out; the import module is the one to ship.

OpenBeken’s config format (mainConfig_t), reverse-engineered and verified byte-exact against a real OpenBK7238 1.18.319 config:

Proven in the emulator: read wifi-ssid/wifi-pass from the stock EF40 → build a mainConfig_t with those creds → write it at 0x1e1000 of a 2 MB-padded OpenBK7238 image → boot: Correct config has been loaded, Using SSID [...], four-way handshake, DHCP lease. End-to-end wifi_migrate.py does the whole read→build→seed chain.

Emulator gotchas worth remembering: pad the QIO image to the real flash size or the NET_PARAM write is out of range; set emu.flash_writethrough = True so config save/read works; UART (incl. the CFG: verdict) prints to stdout, not the log/uart_sink callbacks; and config writes are AND-without-clean-erase, so a later re-save corrupts the blob — hook the flash-program path and capture the first write burst for a clean reference.

Energy-meter calibration: the stock formula, and the OpenBeken mapping

The meter is a BL0937 (the stock driver is registered as bl0937b). Its factory calibration is the EF40 key elecCalibCoef (80 B): u32 magic, a flag byte (1), padding, then three 24-byte records in the order current, voltage, power, each beginning with three int32 a, b, c (the rest of each record is unused). The setter at 0x31160 writes that shape; the getter at 0x311ac checks magic and flag and hands the driver init (0x301c0) the 72-byte payload, which copies each record’s a,b,c into the driver’s calibration object (0x404bbc, mode 1).

The driver’s measurement function (0x41a84; 0x41918 is the uncalibrated twin) works in doubles. From the pulse period it forms the frequency f (Hz) and the HLW8012-datasheet nominal value with these board constants:

quantity nominal from f constants
current I = f · 1.1 · 1000 / 94638 94638 (datasheet), 1 mΩ shunt
voltage V = f · 1.1 · 2040 / 15397 15397 (datasheet), 2040:1 divider
power P = f · 1.21 · 2040 · 1000 / 1721506 1721506 (datasheet), 1.21 = 1.1²

(1.1 plays the role of V_ref; BL0937’s nominal is 1.218 V, which is why the factory trims below come out ~0.88–0.95.) It then applies the factory trim

x_cal = a·x²/1e7 + b·x/1e6 + c/1e6

— the same CoolKit quadratic mvoss96 found in the S60’s calib_coefficient0 (adamsthws/firmware-liberator PR #7). Currents below 3 mA zero current and power. On the dump: a = c = 0 for all three, b = 954547 / 879893 / 930747 (I/V/P): a pure linear trim.

OpenBeken’s BL0937 driver measures the same f — it counts falling edges of CF/CF1 per second (drv_bl0937.c) and scales real = f · cal (drv_pwrCal.c, PWR_CAL_MULTIPLY). So for a linear trim the mapping is exact:

cal_I = (1.1·1000/94638)        · b_I/1e6   = 0.011623 · 0.954547 = 0.011095
cal_V = (1.1·2040/15397)        · b_V/1e6   = 0.145743 · 0.879893 = 0.128238
cal_P = (1.21·2040·1000/1721506)· b_P/1e6   = 1.433861 · 0.930747 = 1.334562

(OpenBeken’s generic defaults are 0.0118577 / 0.1325301 / 1.5 — same magnitudes, which is the independent check that f means the same thing in both.) The import module applies these with CFG_SetPowerMeasurementCalibrationFloat on first boot, and sets PowerMax to the plug’s 3840 W rating. A non-zero a or c has no OpenBeken equivalent; the module then uses b and logs a warning. Not yet done: a check against a real metered load on hardware.

The S61’s wiring (proven OpenBeken template)

The OpenBeken device database carries an entry for exactly this board, S61STPF / WBBK_01P_V1.3, verified on hardware by its author (elektroda.com topic 4157852; OpenBekenIOT/webapp devices.json):

pin role channel
P26 Rel (relay) 1
P9 Btn_Tgl_All (button) 1
P0 LED_n (relay/power LED, active-low) 1
P1 WifiLED_n (Wi-Fi LED, active-low) 2
P8 BL0937CF (power pulses) –
P6 BL0937CF1 (V/I pulses) –
P24 BL0937SEL –

It agrees with the stock driver: the 6-byte parameter block its init passes to bl0937b::open() ([0x13548c]+0x24 = 26 00 00 00 02 18) ends in 0x18 = P24 for SEL (and 0x26 = 8<<2 | 6 looks like CF/CF1 packed). The template’s loglevel 0 worked around UART logging disturbing pulse counting on BK7238, fixed upstream in 1.18.235 (PR #1930); the 1.18.319 base this project builds on has the fix. The import module applies this template on first boot, plus SetStartValue 1 -1 so the relay restores its last state after a power cut (what the tool sets on the S60). The ESPHome config (devices/sonoff-s61/esphome.yaml, on LibreTiny) uses the same pins (hlw8012: model: BL0937, sel_pin: P24, cf_pin: P8, cf1_pin: P6); see the ESPHome route.

Partition map: the SDK’s 4 MB table is Sonoff’s layout

OpenBeken’s BK7238 build hard-wires CFG_FLASH_SELECTION_TYPE = FLASH_SELECTION_TYPE_2M (sys_config_bk7238.h:374), so stock OpenBeken uses the SDK’s 2 MB partition table even on the S61’s 4 MB chip: OTA at 0x132000, NET_PARAM (config) at 0x1e1000 — both inside the stock app partition, and its OTA slot is one the stock rt_ota bootloader never looks at. The SDK’s 4 MB table (BkDriverFlash.c, bk7231_partitions_4M) is, to the byte, the Sonoff layout — the same Beken SDK: APPLICATION 0x11000, OTA 0x286000 (= the stock download slot), RF_FIRMWARE 0x3e0000 (= the factory TLV with the radio calibration), NET_PARAM 0x3e1000 (empty on a stock plug), MATTER_FLASH 0x3e2000 (= the EF40 app store), USR_CONFIG 0x3ee000. Building the S61 image with FLASH_SELECTION_TYPE_4M therefore makes OpenBeken’s own OTA download land in the slot the stock bootloader applies from (so both OpenBeken updates and reversion to stock go through the ordinary ota_http), puts its config in a dedicated empty sector, and lets it read the plug’s real radio calibration. hal_ota.h hard-codes the REST /api/ota address separately (0x132000), and rest_interface.c bounds GET /api/flash at 2 MB; both now follow the partition table. Verified in the emulator: the 4 MB build writes its config at 0x3e1000 and 0x1e1000 stays blank.

The build is a FLASH_SIZE=4M switch, in two patches kept in tools/beken/openbeken/ and offered upstream — OpenBK7231T_App#2290 and beken_freertos_sdk#13, see upstream-contributions.md (the recipe is in its README). Packing a whole 4 MB image needed a 4 MB bootloader, and that work showed that the S61’s factory bootloader is the SDK’s shipped bootloader_bk7238_uart1_v1.0.15.bin with the 4 MB FAL table in its tail, byte for byte — three 64-byte 01PE entries (bootloader 0x0+0x10000, app 0x10000+0x250000, download 0x286000+0x15A000; name, flash device, offset, length, CRC-32 over the first 60 bytes). The SDK’s packager generates that bootloader from the shipped one (make_bootloader_4m.py), so a 4 MB QIO from the build is a converted plug’s whole flash, and the emulator boots it as such. In that run OpenBeken’s ota_http downloaded to 0x286000 and its own bootloader applied it (upgrade(4M.1220->s61v6), _STREAM_END, slot erased); the emulator then stayed at the bootloader banner, but the persisted flash verifies (the written app matches the RBL header’s FNV-1a) and boots s61v6 when loaded afresh — emulator state after an OpenBeken-initiated watchdog reboot, not the image.

Reversion to stock, and the backup

The stock bootloader applies whatever valid RBL sits at 0x286000 — including a downgrade: staging the stock app RBL on a converted plug logged OTA firmware(app) upgrade(s61v2->1.0.2), erased and wrote app, and the stock firmware booted (EasyFlash V4.1.99 is initialize success) with the EF40 untouched (same 33 keys, same Wi-Fi creds, same elecCalibCoef). The stock app RBL is rebuilt from the dump: the app partition holds the decompressed image (the OTA body is gone), so the un-striped app (FNV-1a matches the stored raw_hash 0x0d89f7b1, i.e. byte-exact) is repackaged with the SDK’s own packer exactly as the OpenBeken build does — rt_ota_packaging_tool_cli -f app.bin -o app.rbl -p app -c gzip -s aes -k 0123456789ABCDEF0123456789ABCDEF -i 0123456789ABCDEF -v 1.0.2 — using the same public default AES key as every other RBL, which the bootloader accepts. S61RevertToStock <url> (now AutoflashRevertToStock) in the import module checks the OTA slot is 0x286000 in both the SDK table and START_ADR_OF_BK_PARTITION_OTA (the constant the REST OTA path uses), refusing on a 2 MB-table build where the download would land at 0x132000, and hands the URL to OpenBeken’s normal OTA download. Run end to end in the emulator against a host HTTP server: init OTA, startaddr 0x286000 → reboot → upgrade(s61v6->1.0.2) → stock firmware up, EF40 untouched.

tools/beken/backup.py copies the per-device region 0x3e0000–0x400000 (factory TLV, EF40 app store, system ENV) out of a converted plug over GET /api/flash/<addr>-<len> (the REST reader’s DEFAULT_FLASH_LEN is raised to 4 MB in the S61 build) into captures/plugs/<mac>/sonoff-backup/config-0x3e0000.bin with a manifest and the stock RBL beside it — the S60’s layout. Byte-exact against the dump in the emulator. With the generic stock app and this region, a plug can always be put back to stock.

The robust, update-proof mechanism (the one to ship): an OpenBeken import module (now the driver tools/beken/openbeken/drv_autoflash.c and the S61’s table for it, devices/sonoff-s61/drv_autoflash_sonoff_s61.c). On first boot (only when no SSID is set) it reads the stock EF40 store with beken_hal_flash_read, finds the newest CRC-valid wifi-ssid/wifi-pass, and calls OpenBeken’s own CFG_SetWiFiSSID()/CFG_SetWiFiPass(). Because it uses the setters rather than writing the blob, it is immune to mainConfig_t layout/CRC changes across OpenBeken releases — the offline-blob route above is version- fragile (it broke on exactly the signed-CRC and sizeof details) and is kept only as a fallback / emulator aid. The module’s EF40 scan + CRC + extract logic is validated against the dump; building it into an OpenBeken image and flashing it is the remaining on-device step. It also only reads the stock store, so it is fully reversible.

The ESPHome route (LibreTiny)

The second open firmware for the S61 is ESPHome on LibreTiny, whose 1.13.0 (the version ESPHome 2026.10.0 pins) has the BK7238. The config, devices/sonoff-s61/esphome.yaml, is the S60’s with the S61’s wiring and storage, and needs no change to LibreTiny or ESPHome; the build, the reversion and the validation are in devices/sonoff-s61/esphome.md. What the route rests on:

Tools (reproducibility)

The above was worked out with scratch drivers - find_dlpart.py (locate the download partition via 0x803000 read logging), rbl_pack.py, s61_ota_probe.py (push upgrade, serve a 404, prove the server redirect), s61_ota_flash.py (build the FOTA container, serve the ranged download, log flash writes), s61_stage_boot.py, app_apply_test.py, obk_apply_test.py and boot_qio.py. What they proved is written up above, and what a conversion needs of them is in tools/beken/, run in the emulator’s container:

The BK7238 UART transmit register is 0x0080210c: a test image can write to it to show it has booted. No per-device identifier from any run is kept in these notes.

The button press (the pairing trigger)

On real hardware you press and hold the button for 5 s to put the plug into pairing. The button’s hardware and driver path is understood and can be driven in the emulator through the firmware’s own code up to arming the real 5-second long-press timer; what is not yet reached is the app-layer path that turns that long-press into pairing entry (the physical long-press drives the relay, not the eWeLink pairing trigger — see below).

How the button works on this firmware

The button is a GPIO 0–5 capture interrupt on the PWM/timer block at 0x802B00 (three groups of 0x40, two channels each = pins 0–5). It is not the GPIO INTEN bitmask at 0x802884 — which is why earlier attempts driving pins 10/11 did nothing; the button interrupt lives on a different peripheral.

Interrupt chain:

pin edge
  -> PWM-capture interrupt (status in bits 30/31 of the group reg)
  -> ICU IRQ line 9
  -> ISR 0x1bfd8        reads group status, finds the channel,
                        dispatches callback(r0=pin, r1=level) from the
                        per-pin table at 0x4038f4
  -> key handler 0x4203c
  -> debounce           reads the pin via 0x3eb30, counter threshold > 2
  -> on a held press: 0x42078 arms a 5-unit timer (0x1348fc, r1=5 = "hold 5 s")
                      and posts a key event

Registration

key_add (0x41f64) wires the button up:

The block ISR is registered by the block init 0x1bc18 (intc_register(9, 0x11, 0x1bfd8)).

The long-press is driven by that key-driver timer thread (entry 0x3eba0): it waits on a signal (0x134618, timeout −1 = forever), then walks a timer list, decrements each countdown, and fires expired callbacks (blx at 0x3ec18); the periodic scan is 0x421e0. The armed 5-unit long-press timer sits in that list.

After the long-press: into pairing

The long-press callback posts a key event that the eWeLink app key task consumes and turns into pairing entry. Pairing then runs through the pairing-method dispatcher 0x21eb4 (method byte 0=airkiss → airkiss_start 0x21e50, 1 → 0x21d18, 2 → 0x21d90), fed by the config builder 0x74870 / method runner 0x748cc, driven by PAIR_EVT_START_{COEXIST,EWELINK,BCM} (name table 0x49c18) through the AIS appfw state machine (state struct 0x404fb4, state byte 0x404fb8, functions 0x48a88..0x496b8, appfw loop ~0x49b40). Reaching pairing starts soft-AP / BLE / AirKiss commissioning; completing a Wi-Fi join from there still needs the app + cloud (or a Matter commissioner).

Why it is dormant in the emulator

The whole eWeLink app layer — ais_appfw_task, the button registration (key_add), the provisioned-check — never starts because the app RT-Thread hangs in a factory device-identity self-check. See The flash-UID integrity gate below; correcting it makes the firmware boot the app framework and register the button by itself, in proper RTOS context. The block init 0x1bc18 does run at boot (it is generic); key_add and the key subsystem do not, only because they are downstream of the hung app-start.

One emulator-modelling fact still matters for a simulated press:

The flash-UID integrity gate (root cause)

Boot creates every RTOS task through ais_evt_task, then the app task runs AIS app-start 0x24430 → 0x29b54, which reaches a poll loop at 0x29c38: it builds SHA256(hex(flash_uid) + identity_fields) (hash 0x25454, string via 0x19b70) and memcmps it (0x42788) against a stored 32-byte blob at ctx+0x84. On a mismatch it branches to 0x29c44, an infinite 1-second-delay loop — a deliberate integrity-gate hang, confirmed by a stack unwind that finds the app task parked there (0x24430→0x29b54→0x29c4a→delay 0x1349b4).

The 16-byte identity is the SPI flash chip’s Unique ID: 0x2856c → 0x21408 opens the flash device and reads bk_flash_get_uid (SPI command 0x4B). The boot fix spi_flash.py answers 0x4B with a synthetic UID (ASCII S61EMU\0\1 = 533631454d550001ffff…), so the computed hash (e3f30c7e…) never matches the dump’s real-UID-derived stored value (f009c2a1…) → the app task hangs forever → no appfw, no button, no pairing. A real unprovisioned plug passes this gate (its own flash UID matches its own factory cert) and proceeds to pairing-ready. The real UID is a 128-bit hardware value not recoverable from the dump (the config’s secureCertSha256 / chip-factory.uniqueId* do not match the runtime target), so a legitimate pass needs the real plug’s flash UID; otherwise the gate must be satisfied as an emulator opt-in.

Proven effect of clearing the gate (force the 0x29c3c memcmp result to “equal”, the real-HW outcome — tools/beken/integrity_gate.py): within ~3.5 s the firmware takes the verified path, app-start continues, key_add runs (the button registers itself on GPIO pins 0 and 2), the provisioned-check 0x48cc4 runs, and ais_appfw_task is created — all naturally, none force-called.

What the simulation achieves (reproducible)

archive/s61-research/button_sim.py drives the firmware’s genuine key stack using the Thumb-bit force-call harness (see Tools and methods):

  1. force-run the block init 0x1bc18;
  2. force-run key_add (0x41f64) with a descriptor bytes([0,0,PIN,PIN,PIN,PIN,0,0,0,0,0,0]) at scratch 0x403d80;
  3. “press” = drive the GPIO low and set the channel’s capture bit (state.pwmn_stat |= 1 << (2*GROUP + CH), with GROUP, CH = PIN//2, PIN&1).

Holding the level low needs the pin kept input-readable: 0x3eb30 decodes the per-pin mode (0x1a198) from gpio_cfg bits 2/3, and the capture config reads the non-input path, so the tool forces gpio_cfg[PIN] = (… | 0x0c) & ~0x1. A single capture edge only debounces to 1, so the press re-triggers ~6 edges like a real held button to pass the > 2 threshold.

Result, entirely through the firmware’s own code:

ISR(0x1bfd8) -> handler(0x4203c, r0=pin) -> press_path -> 5s-longpress-timer armed @0x42078 (timer_start r1=5)

i.e. the real ISR runs, dispatches to the real key handler with the correct pin id, the handler runs its real debounce, and arms the real 5-second long-press timer.

The key-driver timer thread’s wake: the bk_timer = Timer 5

The long-press timing is driven by the key-driver timer thread (entry 0x3eba0): it blocks on the RTOS wait 0x134618 → 0x151a8 (svc #0, waits on its TCB signal [tcb+0x54]), and on each wake walks its software-timer list once and runs the periodic key-scan 0x421e0, which reads the button pin and counts the hold. Its wake is rt_thread_resume (0x15274, Thumb veneer 0x134818) called by the giver 0x3ec30, registered by the thread startup 0x217ec on the bk_timer hardware-timer device (0x19ed0 device-control, bk_timer_initialize). bk_timer is Timer 5 (int-status bit 9 of timer3_5_ctl; period 0x7d00 at 0x802a44).

The emulator’s _pacing auto-raises only Timer 3 (timer3_5_ctl bit 7, ICU bit 8) — that is the RTOS tick on the OpenBK/Tuya firmwares — so Timer 5’s interrupt was never raised, the giver never ran, and the thread was signalled 0 times (wait=1, nodeproc=0 forever). Raise Timer 5’s status bit and the giver fires, the thread wakes, the scan runs, and the hold is timed — proven with archive/s61-research/long_press.py (scan runs hundreds of times, long-press action 0x42250 fires). The script raises timer3_5_ctl bit 9 itself because a blanket per-enabled-channel raise in _pacing (what the BK7252 path does) regresses 3 other BK7238 self-tests that drive Timers 4/5 at their own rate; a clean fork fix needs faithful per-timer-period modelling, not a slow-tick raise.

The long-press action 0x42250 posts through 0x3eb6c → 0x12c180 → bk_gpio_output — i.e. it drives the relay/LED, and the scan reads pin [0x404c25]. This is the relay path, not the eWeLink pairing trigger.

Where it currently stops: the appfw pairing event

The pairing state machine is alive and cycling once the gate is cleared: ais_appfw_task (entry 0x319a8) loops at 0x319c4, waiting (~30 Hz) on rt_event_recv (0x134da8, mask 7) on the event object [0x4040e0]. Pairing runs an appfw state handler → config builder 0x74870 → method runner 0x748cc → dispatcher 0x21eb4 (→ AirKiss 0x21e50 / 0x21d18 / 0x21d90) or Soft_AP_start 0x137c80. Posting a raw event to the appfw (rt_event_send 0x134674 to [0x4040e0]) wakes it and re-runs 0x748cc, but it does not pair: no pairing method/params are configured, so the runner is a no-op.

The real trigger sets the method first: the pairingMode command handler 0x492ae (which needs a cloud/BLE session argument — arg1+0x88/0x90 — so it cannot simply be force-called), or the button long-press’s app-layer path. On this firmware the physical-button long-press (0x42250) drives the relay; the eWeLink pairing long-press is a separate app path that still has to be traced to where it sets the pairing method and posts PAIR_EVT_START (names at 0x49c18).

Driving the pairing methods: soft-AP and station mode come up

The method dispatcher 0x21eb4(r0=config) reads config[0]: 0 = coexist (0x21e50 → soft-AP 0x21a80 and the AirKiss sniffer via the RW task), 1 (0x21d18, station — sets Wi-Fi mode via 0x20e00), 2 (0x21d90). The runner 0x74870 hard-codes config[0]=1; the coexist/AirKiss method comes from a different runner / the RAM-resident 0x21eb4 caller 0x3f60c.

Called in the appfw task’s own context (hook loop 0x319c4 + the force-call harness, so the RTOS/RW-task messaging runs in a real task), the dispatcher drives the emulated radio into the right mode — verified with archive/s61-research/pair_drive.py:

So the firmware’s pairing-start code runs and the modelled radio follows it into AP / station mode.

What the soft-AP actually does: beacon only (no dhcpd, no listener)

Does a phone join the soft-AP and POST credentials, as in eWeLink’s classic AP pairing? No. The soft-AP in the COEXIST path is a bare beaconing AP in the emulator, and it carries no credential-provisioning server in any case.

The coexist handler 0x21e50 runs in two parts. The first part is the soft-AP bring-up 0x21a80 (build the AP-config struct — 0x219e8, 0x21920, ending 0x24248); this completes and the MAC beacons. The second part — the continuation at 0x21e80 that calls the IP-services/dhcpd setup (0x21ea4 → 0xfdf0c; the netif-IP config 0xfe4ac and the role-gated path whose failure logs warning: dhcpd no open) — sits behind two RivieraWaves kernel sends, 0xee350/0xee35c (messages 0x64/0x65 via 0xee048 → 0xedfb4). A call-sequence trace (method 0) ends exactly there:

disp → coexist_21e50 → softap_21a80 (→219e8→21920→24248) → rw350 → rw35c → ⊥

0xee35c never returns into the handler: in the unprovisioned/emulated state 0xedfb4 returns -1 (its destination KE task at *0x417564 is not registered), and 0xee35c is a blocking retry-with-delay request (0x13499c between tries), which the synchronous force-call harness cannot carry through (it yields the CPU). So the IP/dhcpd continuation is unreachable in the emulator, and a dynamic probe confirms it: driving method 0 and watching 0xfe4ac (netif IP), 0xfdf70 / 0xfdfaa / the dhcpd no open site, and the two port-8081 server builders — none fire; only the beacon path runs. Whether 0xfdf0c’s role-gated path would start a DHCP server for the AP on real hardware is therefore inferred (plausible — the dhcpd code is there) but unobserved.

Independently, there is no AP-mode provisioning listener in the firmware at all. The only TCP/WebSocket server is the port-8081 LAN-control endpoint (http://%s:8081, ws://%s:8081, advertised over mDNS _ewelink._tcp) — the same LAN-control server the S60 opens once online (see cloud-stub.md), gated on the station/online path, never on the soft-AP path; there is no /ap-style cred-POST endpoint in the image. Credentials reach this plug over BLE (EwelinkBleServer, eWeLink + Matter), feeding the common sink (recv ssid/password/timeout → store wifi-ssid/wifi-pass → switch wifi). So the COEXIST soft-AP advertises presence during pairing (and, on real hardware, likely stands up an AP IP/dhcpd); it is not a self-contained credential-provisioning AP. This is why seeding wifi-ssid/wifi-pass directly is the right way to get the emulated plug onto a network — it lands in the same end state without the BLE handoff the emulator has no radio for.

If this project ever implements its own LAN client (to drive a plug on port 8081 once it is online, rather than over the cloud WebSocket), look to iHost, which drives eWeLink devices locally. Its “eWeLink Smart Home” add-on would contain a working implementation of the encrypted LAN-control protocol and the device-key handling — a useful reference for the mDNS _ewelink._tcp discovery, the port-8081 request format, and the AES scheme keyed on the device secret. Note this is a control channel only: the stock LAN endpoint carries no firmware-push route (no /zeroconf/ota_*), so OTA still goes through the cloud-dial redirect — the LAN client would be for exercising an online plug, not for conversion.

AirKiss decode is not implemented in this build — 0x403b20 is a stub callback. 0x403b20 is the monitor-mode frame callback pointer: the RX consumers 0xaaf40 and 0xb37a8 do bl 0x2220c (read *0x403b20) then blx r3 — they call it as a function, once per received monitor frame. The only code that writes it is the register helper 0x221fc (set_monitor_cb(cb){ if(cb) *0x403b20=cb; }) and the clear 0x221bc (*0x403b20=0). 0x221fc has exactly two callers, both in the AirKiss start handler (0x12c666 and 0x12c6a4), and the only non-zero value either stores is the literal 0x12c483 — which is bx lr, a no-op (verified: runtime bytes == flash bytes at 0x12c482). So the registered AirKiss decoder is a stub: monitor frames routed to it are discarded. Confirmed end to end — setting *0x403b20 = 0x12c483 and replaying 11 000+ frames in monitor mode calls the callback for every frame (consumer 0xaaf40 → 0x12c482) with zero faults and no decode. eWeLink kept the AirKiss scaffolding (the start/stop guards, the monitor (ie.airkiss) is not finish yet log at 0x137c90, the register/clear helpers) but compiled out the payload decoder — consistent with the move to soft-AP + BLE provisioning. The coexist RW path (0xee35c → msg 0x65) does not change this: its handler 0x12c640 registers the same stub, and in emulation the send fails anyway (0xedfb4 returns -1 because the destination KE task at *0x417564 is not registered in the unprovisioned state). So there is no AirKiss decode loop in this firmware to close; the working pairing route is soft-AP (above) plus BLE.

Open items (no route ruled out):

Note: the registered button is on GPIO pins 0 and 2, not pin 3 — archive/s61-research/button_sim.py defaults to pin 3 and predates both the integrity-gate finding and natural registration.

Pairing and provisioning routes

Each route below is a way to get the device into pairing / onto Wi-Fi. They share one dependency: the pairing/commissioning machinery runs only once pairing is actively entered (the button, or a command from the app). The button (above) is the on-device trigger; the other routes are listed with their current stopping point.

AirKiss (smartconfig) relay — decoder stubbed in this build

AirKiss would suit a bridge because the phone never associates with the plug — it broadcasts, the plug sniffs. A phone’s broadcast is captured off-air and injected into the emulated MAC. But this firmware’s AirKiss decoder is a stub, so there is nothing on the device to consume the frames (see Driving the pairing methods for the full trace). The two sub-results are worth keeping separate:

The delivery pipeline tooling still stands for any future firmware that does decode: archive/s61-research/airkiss_capture.sh (AX200 monitor capture to pcap) and airkiss_replay.py (replay a pcap into the MAC; --selftest checks the pipeline, S61_RX_OBJ_PTR gates injection on the handler object). Never commit a real capture: AirKiss carries the Wi-Fi credentials in the frame lengths.

Soft-AP setup network

The firmware supports Soft_AP_start / bk_wlan_ap_init and CoolKit AP HTTP provisioning. Soft-AP is command-driven: the device enters COEXIST unconditionally (bk_wlan_set_coexist_at_init) and switches mode on a runtime pairingMode command. Relevant addresses: status printer 0x130c00 (softap:ssid=%s,channel=%d), Soft_AP_start symbol 0x137c80 referenced by 0x21a80, "pairingMode by %d" 0x49548. The command is registered name→handler via 0x27568 (register("pairingMode", 0x492ae)); the handler 0x492ae fetches its value via get_value 0x12200 and then dereferences a per-command session (arg1+0x88/0x90/…). Reaching soft-AP needs a real pairingMode command (from the cloud WebSocket or BLE) so the handler runs with a genuine session/params context; the on-device equivalent is the button entering pairing. Once an AP is up, the emulator’s peer takes the station role (device-as-AP is modelled — the OpenBK7238 AP self-test passes), so HTTP provisioning would run over the emulated link.

BLE commissioning

The device offers ewelink_ble_pairing and Matter BLE commissioning. Current findings:

Monitor-mode RX injection

To accept relayed AirKiss frames, the MAC delivers received frames while in monitor mode even though the smartconfig path never drives the normal RX-enable handshake (it sets the wide-open filter 0x7fffffde but the scan state machine stays out of HW_ACTIVE). This delivery is general and correct; note only that in this firmware the frames reach a stub callback (the EZ decoder is compiled out — see above), so the capability matters for other BK7238 images rather than for decoding this one.

Implemented in the fork (src/wifimac.py) as an opt-in so it cannot affect normal operation: a flag monitor_rx_deliver (default off; env BK_MONITOR_RX=1 or mac.monitor_rx_deliver = True). When off, behaviour is identical to upstream (the OpenBK7231T Wi-Fi station regression tests pass 3/3 with it off). When on, inject() and _rx_flush() deliver frames if the MAC is HW_ACTIVE or (monitor and monitor_rx_deliver).

Delivery is reliable (100 %, sent == rx) given two conditions, both handled by the replay tool:

RX FIQ path (for reference): vector 0x1c → FreeRTOS context-save stub 0x108c0 → C dispatcher 0x10950 → RAM-resident UMAC glue → ARM veneer 0x3f1520 → 0x16618, which does if (rtos_is_in_interrupt()) { obj = *0x403c10; process(obj); } (rtos_is_in_interrupt = the RAM pair 0x3f0fb4/0x3f0f9c), processors 0x17a20 → 0x1744c. MAC RX interrupts are FIQ (lines 16–22); the firmware drives RX off TIMER_RX_TRIGGER (bit 17).

The connect path (reference)

When the device does connect, the path is: BekenWiFiDriver connect 0x74bfc (“stop monitor, then connect to ssid”, gated if arg3!=0) → 0x74b90 → 0x223f0 (bk_wlan_sta_init config + scan/connect start). The config is a network_InitTypeDef_st (wifi_mode@0, wifi_ssid@1, wifi_key@~33, dhcp@161; ~158 bytes); sta_init is 0x21bf8. Connect first stops monitor via 0x21dbc → 0x20d14 → sub_ee3c9 → sub_ee049 → sub_edfb5.

sub_edfb5 is the RivieraWaves RW-task message send: it builds a message and either processes it synchronously (if in_rw_task() — sub_134bed(0x417564) — and a flag bit is clear) or posts it to the RW task and waits on a semaphore for the result. Stop-monitor is also queued via wpa_hostapd_queue_command (sub_db0f9) to the wpa_supplicant command queue (handle *0x417474, queue 0x421cf8, cmd 116), processed by wpas_thread. These operations complete only when driven by the firmware’s real task context and MAC-event sequencing; a mid-run force-call from another context does not complete the round-trip. IsWiFiStationProvisioned is 0x7388e (reads wifi-ssid via 0x5ad84 → 0x12f438), reached from CHIP DriveStationState 0x73b28.

The fork (../tools/beken/emulator/)

The emulator is upstream BekenEmulator at the commit UPSTREAM pins - the archive on the release emulator-source-beken-<commit>, whose hash UPSTREAM also pins - with these patches applied in name order when the image is built (bk-emu.sh ensure builds or pulls it; bk-emu.sh dump fetches the public S61s dump from upstream’s repository; bk-emu.sh test runs upstream’s self-tests under them, fetching its other devices’ dumps the same way). Each is one commit’s worth, with its reasoning in its header; none has been offered upstream yet.

To change the fork: extract the archive (captures/emulation/cache/), apply the patches to it in order, make the change as a commit there, git format-patch it into patches/, and bk-emu.sh ensure builds the image again from the new set (its hash, bk-emu.sh tag, changed; the workflow publishes that version once it is pushed). Moving to a newer upstream commit: put its hash in UPSTREAM, bk-emu.sh source makes the new archive (git archive of that commit, so anyone can reproduce its bytes), publish it as the release emulator-source-beken-<its commit> (draft first, with the archive attached, then publish: releases are immutable), pin its SHA-256 and name in UPSTREAM, and rework whatever patches no longer apply.

Tools and methods