The DJI's grid flag is labeled charge type

Update, 2026-11-02: The station's minutes field has a second meaning. Byte 4 of its battery record (tag 0x3020) is a time type, and while it is 1 the minutes are a recharging time, the time to full charge, not a runtime. The bridge now leaves runtime_min out whenever that byte says recharging, and the observer ignores the field while input power flows. The story is in The UPS said 2 minutes at 95 %.

Update, 2026-11-02: The bridge no longer connects and disconnects every 20 seconds as described below. Since 2026-10-20, after the station stopped advertising over Bluetooth for hours, it holds one persistent link and acknowledges the station's 0x62 pushes. See The DJI went silent for four hours.

Correction, 2026-11-02: An earlier version of the last section below, and of the issue #33 entry in its Sources, merged two people from issue #33. The person who opened the issue, reporting the station rejecting its pair_key with status 03, didn't post a power-cycle result. The full power cycle and the "months" of uptime came from a second commenter in the same thread, whose Power 2000 had dropped off with "no recent advertisement" rather than a status 03. One station that had both symptoms, a status 03 rejection and then no advertising, and was cleared by a full power cycle is in issue #32.

Part 1 ended on a byte. The community protocol notes for DJI's power stations list offset 46 of the base-information record as "Grid connection status", unused by any integration. The plan for the DJI Power 2000 was short: log in over Bluetooth, pull the station's plug out of the wall, watch byte 46 flip.

Byte 46 read 0 with the plug in. It read 0 with the plug out. The byte that flipped, every time, was offset 52, which the same document calls "Charge type".

Plaintext Bluetooth, with a login#

The Power 2000 is much easier to talk to than the AC180. The ESP32 bridge has to run a signed P-256 key exchange and AES-CBC before the BLUETTI will answer a single register read. The DJI speaks DUML, the framing DJI uses across its products, in plaintext: a 0x55 start byte, a length, a CRC8 over the header, source and destination, a sequence number, a command set and command ID, the payload, a CRC16.

There is still a lock on the door, but it's at the application layer. Command 0x6A with 00 returns a 4-byte challenge. Command 0x6A with 01, the challenge, a 32-character pair_key and a trailing 00 returns status 00, and the station is yours. The key is per station and comes from the DJI account, once: the setup flow of ha-dji-power-ble signs in (image captcha included) and fetches it, and a small script making the same cloud calls did the job here. After that nothing touches DJI's cloud, which matters for a monitor that has to keep working when the internet is down too.

Right after the login, the station starts pushing on its own: 0x62, a config dump, about every ten seconds, and 0x61, the live report with battery, totals and one row per port, about once a second. No requests needed. All the work is in reading what it sends.

Input watts read zero on both sides of the plug#

The obvious grid signal is input power. The capture says otherwise. The station sat at 78 %, nothing plugged into its outlets, and the plug came out of the wall during a capture on the PC. Decoded with the Python reference decoder, two reports thirteen seconds apart (local time):

TimeGrid0x61 lengthTotal inTotal outAC inlet row
12:29:49plugged in139 bytes0 W0 Wpresent, 0 W in
12:30:02pulled110 bytes0 W0 Wgone

An idle, charged station on grid draws nothing, so its watts look exactly like a station on battery with no load. Part 1 found the AC180's input watts mixing load and charging. On an idle station the number doesn't mix anything: it is zero on both sides. The only difference is structural. The report shrinks by 29 bytes because the AC inlet row disappears. That row is a usable fallback, but a missing row is a weak thing to stake a shutdown on: it is also what a truncated or misparsed report would look like.

Byte 52 flipped, byte 46 never did#

The 0x62 push carries keyed records, and key 0x00 is the base information: 53 bytes on this station. Diffing consecutive copies of that record across the unplug and, in a later capture, the replug gives one offset each time:

cap2.jsonl first record len 53 byte46= 0 byte52= 1 capacity_wh= 2048 fw= 01.00.1500
cap2.jsonl t=58.8 changed (offset,old,new): [(52, 1, 0)]
cap4-2.jsonl first record len 53 byte46= 0 byte52= 0 capacity_wh= 2048 fw= 01.00.1500
cap4-2.jsonl t=132.0 changed (offset,old,new): [(52, 0, 1)]

The same record decodes cleanly elsewhere: 2048 Wh capacity, firmware 01.00.1500, so the offsets line up with the document. Byte 46 is 0 in every record from every capture. Byte 52 goes 1 → 0 when the plug comes out and 0 → 1 when it goes back in. Counted from the start of the whole 0x62 payload, past the 16-byte header, a 17-byte record for key 0x0E and key 0x00's own 4-byte header, the same byte sits at offset 89, which is how the first capture notes recorded it.

The protocol notes give byte 52 as "Charge type: 0 unknown, 1 normal, 2 maintenance". Read that way, the station isn't reporting the grid at all: unplugged, it can't charge, so the charge type drops to "unknown". Here that works as a grid flag, with one condition. In these captures the report listed no DC input at all, only the AC inlet and the AC outlets, so the wall was the only way to charge. With a solar panel on it, byte 52 could plausibly read "normal" in the middle of an outage. Type 2 hasn't been seen either: the notes say it "has not yet been captured from a station". The bridge treats any non-zero value as grid present:

// 0x62 config push: 16-byte header, then records key(1) 0x10 len(LE16) value. Key 0x00 = base info;
// its byte 52 flipped 1 -> 0 on unplug and back on replug (plug-pull tests 2026-10-15).
static void dji_parse_config(const std::vector<uint8_t> &pl, DjiReading &r) {
  const uint8_t *p = pl.data();
  size_t n = pl.size(), o = 0;
  if (n >= 16 && p[2] == 0x10 && p[3] == 0x00) { p += 16; n -= 16; }
  while (o + 4 <= n) {
    uint8_t key = p[o];
    uint16_t l = le16(p + o + 2);
    if (p[o + 1] != 0x10 || o + 4 + l > n) return;
    if (key == 0x00 && l >= 53) r.grid = p[o + 4 + 52] ? 1 : 0;
    o += 4 + l;
  }
}

The published on_battery comes from that byte. If the 0x62 push didn't arrive during a poll, it falls back to the inlet row, and if neither arrived it is null, never a guess. What byte 46 tracks is still open: in every capture here, on grid and off, it was 0.

The captures above were the ones that survived. Most didn't. In one batch of thirteen capture runs from the PC, twelve ended the same way, and a later batch lost five of six:

Error (RuntimeError): Station disconnected during capture

The natural reading is that the station kicks off anything that isn't DJI Home. btmon disagreed. Here is how the PC asked for the connection, with the station's address masked:

< HCI Command: LE Extended Create.. (0x08|0x0043) plen 26  #69 [hci0] 27.033589
        Filter policy: Accept list is not used (0x00)
        Own address type: Public (0x00)
        Peer address type: Public (0x00)
        Peer address: XX:XX:XX:XX:XX:XX (OUI XX-XX-XX)
        Initiating PHYs: 0x01
        Entry 0: LE 1M
          Scan interval: 60.000 msec (0x0060)
          Scan window: 60.000 msec (0x0060)
          Min connection interval: 30.00 msec (0x0018)
          Max connection interval: 50.00 msec (0x0028)
          Connection latency: 0 (0x0000)
          Supervision timeout: 420 msec (0x002a)

And here is how the first connection ended, 45 seconds later:

> HCI Event: Disconnect Complete (0x05) plen 4            #321 [hci0] 72.624193
        Status: Success (0x00)
        Handle: 512 Address: XX:XX:XX:XX:XX:XX (OUI XX-XX-XX)
        Reason: Connection Timeout (0x08)

The supervision timeout is how long either side waits without hearing a valid packet before it declares the link dead. Linux asked for 420 ms. btmon ran during that second batch. Five of its six connections lasted 45.5, 18.8, 4.8, 9.9 and 3.9 seconds, and all five ended with reason 0x08. The sixth ran until the host closed it at the end of the capture. A station that hangs up on purpose sends a termination of its own, and that arrives with a different reason. Nobody hung up. A packet or two went missing for 420 ms and the link layer gave up. The station sits behind a rack: its advertisements reached the PC at a median of −69 dBm across 189 samples, and as low as −85.

The fix is one call when the bridge connects. NimBLE takes the timeout in 10 ms units:

static const uint16_t BLE_SUPERVISION_TIMEOUT = 400;
client->setConnectionParams(24, 40, 0, BLE_SUPERVISION_TIMEOUT);  // 30-50 ms interval

Four seconds instead of 0.42. The bridge applies it to both stations.

One board reads both stations#

The port went into the existing bridge as one commit: 344 lines added to the sketch, covering the frame encoder, both CRCs, reassembly of reports that arrive split across notifications, the login, and the two parsers. Built with the DJI code switched off, the image is 9,280 bytes smaller. Before it went near the board, the C++ parsers were run on the PC against the same three captures as the Python reference decoder: 214 decoded packets, identical output line for line.

The bridge doesn't hold a connection the way the Home Assistant integration does. Each 20-second cycle runs one Bluetooth scan that finds both stations, reads the AC180, then connects to the DJI, logs in, keeps the first 0x62 and the first 0x61, and disconnects. The DJI adds 2–3 seconds to the cycle and publishes on its own topics, ups/dji2000/state and ups/dji2000/bridge, with its own last will.

Pulling the plug over MQTT#

The end-to-end test was the same one as before: plug out, watch the broker. The plug came out at about 11:12:38 UTC. The retained reading from the previous cycle, then the first new one:

2026-10-15T11:12:38+0000 ups/dji2000/state {"ok":true,"rssi":-70,"soc":76.0,"runtime_min":5940,"temp_c":26.6,"out_w":6,"in_w":22,"ac_out_w":6,"ac_in_w":22,"grid":true,"inlet_row":true,"on_battery":false,"ts":1792062740,"interval_s":20,"uptime_s":654}
2026-10-15T11:12:41+0000 ups/dji2000/state {"ok":true,"rssi":-62,"soc":76.0,"runtime_min":5529,"temp_c":26.5,"out_w":4,"in_w":0,"ac_out_w":4,"ac_in_w":0,"grid":false,"inlet_row":false,"on_battery":true,"ts":1792062761,"interval_s":20,"uptime_s":674}

Three seconds, mostly luck: the cycle happened to come round. The worst case is about one interval plus the poll itself. Every reading in these logs, plugged in or not, was ok:true, at −61 to −70 dBm from the board.

The station's own remaining-time figure is worth a look while it's unplugged. At 76 % and 4–6 W out, consecutive readings said 5529 minutes, then 3578, then 5071, then the same three again. Ninety-two hours, then sixty, then eighty-four, round and round. It goes into the published JSON as runtime_min because it's what the station says, and the observer treats it as one estimate among several.

After the plug went back in, the first reading the log caught said grid: true, with 1173 W coming in.

What the bridge still can't see#

Two failure modes are outside anything the bridge can fix. The Power 2000 accepts one Bluetooth central at a time, so DJI Home over Bluetooth and the bridge take turns. The app over Wi-Fi doesn't conflict.

The second is worse. In issue #33 of ha-dji-power-ble, a Power 2000 began rejecting the pair_key fetched from its own account with status 03. In the same thread a second Power 2000 dropped off with "no recent advertisement", and only a full power off and on brought it back; asked how long it had been up, its owner answered "months". In issue #32 one station had both symptoms, a status 03 rejection and then no advertising at all, and a full power cycle cleared both. On a UPS, a full power cycle also drops the AC outlet, which is the one thing the setup exists to prevent. That changes the contract for whatever consumes these readings: a DJI that stops answering for minutes is an alert in its own right, never a sign that the grid is fine. The observer gets that rule.

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

Sources#

  • zuyan9/ha-dji-power-ble — Home Assistant integration for DJI Power stations over BLE (0BSD); the protocol code the bridge ports, and the pair_key fetch.
  • ha-dji-power-ble protocol notes — DUML framing, the 0x6A login, and the base-information table: offset 46 "Grid connection status", offset 52 "Charge type".
  • ha-dji-power-ble issue #33 — a Power 2000 rejecting its pair_key with status 03, and in the same thread a second one that stopped advertising after "months" of uptime until fully power-cycled.
  • ha-dji-power-ble issue #32 — a Power 2000 that rejected its pair_key with status 03, then stopped advertising; a full power cycle cleared both.
  • hiromo/ha-dji-power-bt — a second, independent local BLE integration for DJI Power stations (Apache-2.0).
  • NimBLE-Arduino — the BLE stack on the ESP32; setConnectionParams takes the supervision timeout in 10 ms units.
  • bluetti-bridge — the firmware from this post, MIT, with the DJI code derived from ha-dji-power-ble.
  • DJI Power 2000 specs — Bluetooth 5.0 and Wi-Fi, 2048 Wh.