The UPS said 2 minutes at 95 %
Contents
My storage box and gaming PC run on a DJI Power 2000 power station used as a UPS, and a monitor on my router decides when they shut down. At 11:39:39 on 1 November, twenty-three minutes into a power cut, my alert channels got this from it:
ups dji2000-t2: LOW BATTERY, its hosts should shut down (runtime 2m0s <= 20m0s (ABC))
And the same for the third tier, against its 8-minute limit. Two minutes of runtime left on the DJI. I could see the station's display at the time. It said about 95 %.
The sender was the observer, the Go service on my router that reads both power stations over MQTT and serves them to the hosts as NUT UPSes, three tiers per station, each tier with its own shutdown limits. Nobody shut down because of it, which was luck more than design.
Three runtime estimates and a minimum#
The observer doesn't trust any one runtime figure. It computes three and serves the smallest:
- A, an energy model: the charge, the peak load seen, a 2048 Wh capacity, 82 % inverter efficiency, a 10 % reserve and 15 W for the station itself.
- B, the slope of the charge over at least ten minutes on battery.
- C, the DJI's own remaining-minutes field, used only while the station is on battery.
The alert names which estimates existed, (ABC), not which one won. That had to be worked out.
It wasn't A. At 95 % the energy model has over 1,400 Wh to spend; for it to say two minutes, the station would have to be feeding over 40 kW. It wasn't B either: getting from 95 % to empty, or to the reserve, in two minutes takes the charge falling by more than 40 points a minute. Only C could say 2.
The minutes field has a type byte#
The DJI sends its battery state in the 0x61 report, as a record tagged 0x3020. The protocol notes of ha-dji-power-ble, the Home Assistant integration my bridge's DJI code comes from, give the minutes and the type byte; the charge in the first two bytes is how the bridge's own parser reads it:
| Bytes | Meaning |
|---|---|
[0:2] | charge, percent × 100 |
[2:4] | a duration in minutes, u16 |
[4] | time type: 1 recharging, 0 or 2 remaining |
With time type 1, the integration shows that duration as Recharging Time and leaves Remaining Time unavailable. The same two bytes are runtime or time-to-full, depending on the byte after them.
The bridge read the two bytes and never looked at the third:
r.runtime_min = le16(v + 2);
Part 4 watched this field with the station unplugged: 5529, 3578 and 5071 minutes at 76 % and 4–6 W, then the same three again, and the observer treated it as one estimate among several. That still holds while the station discharges. What it means while the station recharges wasn't known then.
The grid came back before the flag did#
Here is the outage in the observer's log, in local time:
| Time | Event |
|---|---|
| 11:16:40 | DJI on battery, grid lost |
| 11:21:40 | tier t1 low battery, five minutes on battery (t1's time rule then) |
| 11:39:39 | bridge's MQTT connection offline, then online; in the same second, t2 and t3 low battery, runtime 2m0s |
| 11:40:16 | DJI on line, grid back |
| 11:45:16 | grid back for five minutes, tier low-battery latches cleared |
Nothing recorded the type byte that morning, but one reading fits everything. The grid reached the DJI before the bridge's grid flag said so. The station started recharging, and its minutes field became the time to full: two minutes. The observer was still in the on-battery state, where C counts, and took a recharge estimate as a runtime estimate. A minimum is built to let the most alarming input win, and it did.
Nobody went down because nobody was left on those tiers. The storage box, the one machine on the DJI's second tier, had already been shut down by hand at 11:33, for an unrelated reason that the next part gets to. Nothing else sits on t2 or t3 of the DJI. With the storage box still running, a station at 95 % that had just got its grid back would have shut it down.
Fixing the bridge and the observer#
The fix went in on both sides.
The bridge firmware now publishes the field only when the time type isn't recharging:
if (v[4] != 1) r.runtime_min = le16(v + 2);
The observer gets its own guard, for older bridge firmware that publishes the field either way. While input power is flowing, the station is charging, and its minutes aren't a runtime:
charging := r.InW != nil && *r.InW > 0
if s.power == PowerOB && r.RuntimeMin != nil && *r.RuntimeMin > 0 && !charging {
s.lastRtC, s.haveRtC = time.Duration(*r.RuntimeMin*float64(time.Minute)), true
}
That is broader than the integration's rule, which calls the station charging only with time type 1 and input above 0 W, because input can also feed the outputs straight through. For this guard the difference doesn't matter. In the on-battery state, input power is what a returning grid looks like, and dropping C leaves A and B, which both still know the charge is 95 %.
A test replays the morning: 95 %, on battery, two normal readings, then one with 400 W of input and a minutes field of 2. With the guard turned off, it fails with this message:
dji2000-t2: time-to-full taken as runtime: "OB DISCHRG LB" reason="runtime 2m0s <= 20m0s (AC)" runtime="120"
The same two minutes, the same tier, the same low battery. It says (AC) where production said (ABC), because three readings aren't the ten minutes on battery that B needs. With the guard on, the test and the full suite pass under -race.
The observer went live with the guard at 11:20 on 2 November, and the bridge was reflashed a few minutes later. Its first readings, back on grid, included:
"soc":100.0,"runtime_min":5940,... "in_w":177, "grid":true
At 100 % on grid the station reports the discharge type, so the field still comes through. 5940 minutes is 99 hours. The protocol notes have seen it too and say a 99-hour reading can be a capped estimate or a fallback. In part 4, the last reading on grid before the plug came out, at 76 %, said 5940 as well.
A minimum of different questions#
Taking the minimum of three estimates feels like the safe choice, and it is, as long as all three answer the same question. A vendor field that changes meaning on a flag byte answers a different question on some readings, with the same units and the same name. The minimum doesn't know, and it picks the smallest number either way.
C now only counts while it is a runtime. A and B never stopped saying 95 % was plenty.
Filed under: go, esp32, bluetooth, ups, power, nut
Read next#
- Three shutdowns, three causes — the same two days from the storage box's side: why it really went down, three times.
- The DJI went silent for four hours — the persistent Bluetooth link this firmware runs on, and another false state from the observer.
- The DJI's grid flag is labeled charge type — the first look at this minutes field, and the grid flag that lagged here.
- Twenty-five percent is a countdown — where the energy-model estimate and the tiers come from.
Sources#
- ha-dji-power-ble protocol notes — the
0x3020battery record: duration at[2:4], time type at[4]; Remaining vs Recharging Time; the 5,940-minute reading. - zuyan9/ha-dji-power-ble — the Home Assistant integration the bridge's DJI code is derived from.