Secure session ready. Battery: ?

Porting a key exchange from Python to C++ should be the boring part. The Python works, it is already validated against this exact station, and mbedTLS ships every primitive it uses: MD5, AES-CBC, P-256, ECDSA, ECDH. Translate, compile, flash. The first run on the ESP32 went along with that, right up to the last line:

found AC180<redacted> XX:XX:XX:XX:XX:XX rssi=-57
connected, mtu=517
KEX: challenge answered
KEX: challenge accepted
KEX: pubkey sent
KEX: pubkey sent
KEX: secure session ready
handshake 1913 ms
RESULT BATTERY_SOC=? DC_OUTPUT_POWER=? AC_OUTPUT_POWER=?

Every step of the handshake reported success. Then came the reads, the part that was supposed to be easy, and the first three came back as question marks. The capture ended there.

The bug turned out to be one message the station sends twice. The Python library this port came from has the same weakness and has never been hit by it here.

Why move the poller onto a board#

Part 1 read the BLUETTI AC180 from a shell loop on the workstation. That works, with three catches: the workstation has to be awake, it has to stay in Bluetooth range, and it is the very machine the UPS is protecting. A monitor that runs on the thing it is monitoring is in the wrong place for the one event that matters.

An ESP32 on a USB charger has none of those problems. The drawer had three of them:

  • a Cheap Yellow Display, already reflashed once, with 4 MB of flash and no PSRAM;
  • a small classic ESP32 running a weather and air-quality sketch, built in March 2026 on ESP-IDF 5.5.4, whose source exists nowhere;
  • an ESP32-S3 DevKitC with 16 MB of flash and 8 MB of PSRAM, still running a MicroPython 1.19.1 build (June 2022) with an "RGB Demo" on it.

The S3 won on memory, though nothing in the firmware asks for its PSRAM yet. Before anything was flashed, the weather board and the S3 itself got full flash dumps. For the weather board that dump is now the only copy of a working program; its strings still hold the format lines wx ok %.1fC code=%d and air ok aqi=%d pm2.5=%.0f. The dumps stay private, because an ESP32's NVS partition is exactly where Wi-Fi credentials and other secrets end up.

The toolchain: arduino-cli 1.5.1, the ESP32 Arduino core 3.3.12 with its bundled mbedTLS, NimBLE-Arduino 2.5.1. The first commit of the port was 479 lines.

What the BLE handshake does#

The station opens with a 4-byte challenge. The bridge turns it into a temporary AES-128 key: MD5 of the reversed challenge is the IV, and that IV XORed with a static key is the key. Under that temporary key the station sends a P-256 public key whose signature checks out against one built-in public key (K2), and the bridge answers with an ephemeral public key of its own, signed with a built-in private key (L1). Once the station accepts it, both sides run ECDH, and the shared secret becomes an AES-256-CBC key. From then on every message carries a 4-byte seed, and its IV is MD5 of that seed.

The static keys are reverse-engineered constants, already public in bluetti-bt-lib; nothing here needs an account or a per-device secret. Each read after the handshake is a Modbus command, 01 03, register address, count, CRC-16 little-endian, one register per field.

Note what the sequence lacks: a step where the two sides prove they derived the same key. "Secure session ready" is the bridge's own opinion. The first time anyone checks is the first read.

"pubkey sent", twice#

The clue was in the log the whole time: KEX: pubkey sent appears twice. The bridge only sends its public key in reply to the station's public key, so the AC180 had sent PEER_PUBKEY twice.

The first version handled each copy as a fresh exchange: new ephemeral keypair, new signature, new reply. The bridge kept the private key from the second round. The station evidently went with the first one (resending the first reply is what fixed it), so the two ends derived different AES-256 keys. With mismatched keys, nothing after the handshake is readable to the other side, in either direction. Every field the bridge tried to read came out as ?.

The fix is to make the reply idempotent. One keypair per session; a duplicate gets the same answer as the original:

case PEER_PUBKEY: {
  if (datalen != 128) { S.failed = true; return true; }
  if (!S.pubkey_reply.empty()) {
    ble_write(S.pubkey_reply);
    Serial.println("KEX: duplicate peer pubkey, resent cached reply");
    return true;
  }

The next capture printed KEX: duplicate peer pubkey, resent cached reply on every poll, and the reads came back with values.

Why Python never tripped#

bluetti-bt-lib 0.1.8 handles PEER_PUBKEY the same way the first port did:

def msg_peer_pubkey(self, message: Message) -> bytes | None:
    _LOGGER.debug("Received peer pubkey, checking signature")
    data = verify_and_extract_signed_data(message.data, self.unsecure_aes_iv)
    self.peer_pubkey = pubkey_from_bytes(data)

    _LOGGER.debug("Generating a local keypair")
    self.my_pubkey, self.my_privkey = generate_keypair()

No check for a key it has already generated. A second copy would replace my_privkey exactly as it did on the ESP32. In the runs here, the second copy never came. Wrapping msg_peer_pubkey with a counter and running real reads from the workstation (two of three attempts connected; the third did not find the station):

Client created
Reader created
PEER_PUBKEY #1 received
[…]
PEER_PUBKEY messages: 1

Both reads saw the station's public key once and returned all 25 fields. Why the station repeats itself to the ESP32 and not to the PC is still open. The two clients differ in Bluetooth stack and timing, and nothing measured so far says which of those matters. If the station ever repeats itself to a PC, nothing in the Python code stops the same bug.

Bench numbers, with and without Wi-Fi#

With the duplicate handled and Wi-Fi off, three complete polls ran back to back before the capture ended mid-fourth. The handshake took 1.86 to 1.91 seconds. A full poll, meaning a 5-second scan, connect, handshake and eight register reads, took 11.3 to 11.5 seconds. Free heap stayed at about 220 KB from the first poll to the third, and signal strength at the desk sat between −57 and −64 dBm.

Then Wi-Fi went on, for MQTT, and both radios had to share the S3. The first poll after boot, broker included:

found AC180<redacted> XX:XX:XX:XX:XX:XX rssi=-57
connected, mtu=517
KEX: challenge answered
KEX: challenge accepted
KEX: pubkey sent
KEX: duplicate peer pubkey, resent cached reply
KEX: secure session ready
handshake 2013 ms
RESULT BATTERY_SOC=100% DC_OUTPUT_POWER=0W AC_OUTPUT_POWER=129W DC_INPUT_POWER=0W AC_INPUT_POWER=179W AC_INPUT_FREQUENCY=49.9Hz AC_INPUT_VOLTAGE=233.5V AC_INPUT_CURRENT=0.8A
poll took 12093 ms, heap=152596, wifi=3
mqtt connect ok (state 0)
mqtt sent {"ok":true,"rssi":-57,"soc":100.0,"dc_out_w":0.0,"ac_out_w":129.0,"dc_in_w":0.0,"ac_in_w":179.0,"ac_in_hz":49.9,"ac_in_v":233.5,"ac_in_a":0.8,"on_battery":false,"ts":null,"uptime_s":12}

Over three polls in that capture, the handshake took 1.86 to 2.01 seconds and a poll 11.9 to 12.1. Free heap read 152.6 KB on the first poll and 149.8 to 149.9 KB on the next two, about 70 KB below the Wi-Fi-off runs (an earlier build, so not all of that is Wi-Fi). wifi=3 is WL_CONNECTED. The build with Wi-Fi and MQTT:

Sketch uses 1130027 bytes (35%) of program storage space. Maximum is 3145728 bytes.

Just over a third of the 3 MB app partition.

MQTT on the router, with a clock and a deadline#

The broker is Mosquitto 2.1.2 in a container on the house router, a MikroTik hAP ax³. Its config, data and container root all live on the router's USB SSD. Anonymous clients are refused (allow_anonymous false); the bridge publishes as one user and consumers read as another.

That SSD needed attention first. RouterOS had been sharing the whole disk on the LAN, over guest SMB and DLNA, and the plan was to put broker credentials and the flash dumps from the drawer on it. Sharing now covers a media/ folder and nothing else. Turning off the disk-level share was its own small rake: /disk/set refuses while anything is writing to the disk, and the DNS ad-blocker was logging to it, so the change runs in a window of about 75 seconds with ad-blocking off.

Each poll becomes one retained JSON message on ups/ac180/state. The bridge's last will on ups/ac180/bridge says offline, and it publishes online when it connects. Part 1's two rules carried over, in slightly looser form. Grid state comes from voltage and frequency, never input watts, and a value that could not be read is null, never 0. Unlike part 1's classifier, the bridge decides from whichever of the two it got:

bool vOk = r.valid[IDX_V], hzOk = r.valid[IDX_HZ];
const char *onb = "null";
if (vOk || hzOk) onb = ((vOk && r.value[IDX_V] < GRID_MIN_VOLTS) || (hzOk && r.value[IDX_HZ] == 0)) ? "true" : "false";

If neither voltage nor frequency came back, on_battery is null, and a consumer has to treat that as "unknown".

Retained messages add a trap of their own. If the bridge dies, the last "on_battery":false stays on the broker indefinitely and every new subscriber receives it. The last will covers a bridge that drops off the network; a payload timestamp covers one that is connected but stuck. A consumer trusts on_battery only when the bridge topic says online and ts is no older than three poll intervals. The interval comes from the same message (interval_s, added later), because it can be changed remotely, and a fixed 60-second limit would start calling readings stale between polls once the interval reached 60. At the default 20 seconds it works out to 60.

That rule needs a clock, and the bridge has none at boot. In the first publish above, ts is null. The bridge asks the router for NTP before pool.ntp.org, and the router now runs an NTP server for the LAN, so the second publish 20 seconds later had one:

poll took 11943 ms, heap=149940, wifi=3
mqtt sent {"ok":true,"rssi":-59,"soc":100.0,"dc_out_w":0.0,"ac_out_w":175.0,"dc_in_w":0.0,"ac_in_w":175.0,"ac_in_hz":49.9,"ac_in_v":238.3,"ac_in_a":0.8,"on_battery":false,"ts":1791987213,"uptime_s":32}

1791987213 is 17:13:33 EEST on 2026-10-14, set via NTP with the router listed first. The router has backup power of its own; the upstream internet may not, and a clock that needed it could be missing in exactly the outage this exists for.

Then the bridge got unplugged to test the will. Its last publish carried a ts of 17:23:53; the plug came out at about 17:24:09. A subscriber that connected right after still received the retained online and the last state with "on_battery":false, exactly the trap described above. The broker published offline at 17:26:10, 137 seconds after the last packet. That is within a couple of seconds of 1.5 times the 90-second keepalive the bridge used then, the grace the MQTT spec allows before declaring a client gone. By the ts rule the bridge was already stale at 17:24:53, 77 seconds earlier. The will works; the timestamp would have caught it first.

The repo, and what came after#

The code is public at gitlab.com/random-home-utils/bluetti-bridge under MIT, keeping the notices of the two projects it derives from: bluetti-bt-lib (© 2025 Patrick762) and bluetti_mqtt (© 2022 Stephen Augenstein). It is one commit with no comments in the code, and a README that covers the topics, the staleness rule, the build, a broker ACL example and the protocol notes, including the duplicate key.

The firmware there has moved on from the version measured above. Two more topics now come out of the bridge: a retained ups/ac180/info with its own health (heap, Wi-Fi signal, reset reason, poll counters) and ups/ac180/log, which holds log lines while MQTT is down and sends them once it is back. Both have been seen arriving on the broker. There is also a command topic, ups/ac180/cmd (reboot, poll now, change the interval, turn on debug logging, scan for Bluetooth devices). Once the broker got an access list and an admin user, every command except reboot and the scan was tested end to end: info, changing the interval, debug on and off, poll, and an unknown command (answered with unknown cmd) all worked, while a publish to the command topic from the read-only user was dropped by the broker. A command that arrives mid-poll waits for the Bluetooth read to finish, up to about 12 seconds. The keepalive dropped from 90 to 30 seconds, which should bring the will down to about 45 seconds; that is also still unmeasured. Firmware updates over the network exist only behind two compile-time flags, both off by default. The board running here has neither, so the only way to reflash it is a USB cable.

The first reflash after that produced one more rake. The board's first Wi-Fi join failed with status 4, WL_CONNECT_FAILED, apparently because the access point still held the old session. The ESP32 Arduino core retries a failed first join once. When that retry also fails with an authentication failure, which is what status 4 reports here, auto-reconnect doesn't count it as retryable and stops. The bridge sat offline for about three minutes, long enough for the will to go offline and the retained state to freeze. A watchdog now re-joins every 30 seconds while MQTT is down and restarts the board after five minutes without it. On the next flash, the same failure showed up in the serial log as wifi not connected (status 4), re-joining, followed by mqtt connected.

Filed under: esp32, bluetooth, ups, power, mqtt

Sources#