The power went out twice. My UPS log saw it once.
Contents
Thirty lines of log, one outage. Twenty-two lines on grid, seven on battery, one back on grid. Clean, readable, and short by one event: the plug came out of the wall twice.
The setup itself works. A BLUETTI AC180 portable power station sits between the wall and my workstation as a UPS. Linux reads it over Bluetooth LE, through an encrypted link, with no account and no pairing, in about nine seconds per read. What it can't do is tell you about anything that happens between two of those reads. Neither can anything else built on polling. This one put it in writing.
No UPS port, only Bluetooth#
A typical desktop UPS has a USB-HID port, and NUT or upower talks to it. The AC180 has none of that: no USB data, no RS-232, no dry contact, no Wi-Fi, no cloud. The phone app reaches it over Bluetooth, so that is the only local data path left. Newer firmware encrypts that link.
bluetti-bt-lib handles the encryption. Its encryption.py carries the reverse-engineered constants right in the source (LOCAL_AES_KEY, PRIVATE_KEY_L1, PUBLIC_KEY_K2) and runs an ECDH exchange on P-256 before the first command. The README lists the AC180 under "Validated". Version 0.1.8 from PyPI, a venv, and the scan:
$ .venv/bin/bluetti-scan
Scanning for 5 seconds (or until Ctrl+C)...
['AC180', 'XX:XX:XX:XX:XX:XX']
Five seconds. Then the read, with -e 1 switching on the encrypted handshake:
$ .venv/bin/bluetti-read -m XX:XX:XX:XX:XX:XX -t AC180 -e 1
Client created
Reader created
FieldName.BATTERY_SOC: 100%
FieldName.DEVICE_TYPE: AC180
FieldName.DEVICE_SN: <redacted>
FieldName.DC_OUTPUT_POWER: 0W
FieldName.AC_OUTPUT_POWER: 146W
FieldName.DC_INPUT_POWER: 0W
FieldName.AC_INPUT_POWER: 142W
FieldName.DC_INPUT_VOLTAGE: 0V
FieldName.DC_INPUT_CURRENT: 0A
FieldName.AC_INPUT_FREQUENCY: 49.9Hz
FieldName.AC_INPUT_VOLTAGE: 237.5V
FieldName.AC_INPUT_CURRENT: 0.6A
FieldName.AC_OUTPUT_FREQUENCY: 49.9Hz
FieldName.AC_OUTPUT_VOLTAGE: 234.5V
FieldName.CTRL_AC: True
FieldName.CTRL_DC: False
[…]
FieldName.CTRL_CHARGING_MODE: ChargingMode.STANDARD
FieldName.CTRL_POWER_LIFTING: False
FieldName.VER_BMS: 1052.03
That one took 9.23 seconds, and it was taken on grid after the test, as a sample of what comes back. The three lines that matter for a UPS are battery state of charge, input voltage and input frequency.
Nothing else on the shelf was a better fit. The official Bluetti Home Assistant integration doesn't list the AC180, logs in to the Bluetti cloud over OAuth, and downloads a prebuilt BLE shared library at runtime. bluetti_mqtt was last pushed in April 2024 and has no AC180 device file. nut-server-bluetti speaks NUT directly and will come up again below. For a first look, a shell loop was enough.
Pulling the plug on the logging machine#
The logger is one loop. It reads, keeps five fields, appends a line, and sleeps:
#!/bin/bash
# Spike logger: AC180 key fields every ~10 s -> ac180-poll.log
cd "$(dirname "$0")"
while true; do
ts=$(date +%T)
out=$(timeout 40 .venv/bin/bluetti-read -m XX:XX:XX:XX:XX:XX -t AC180 -e 1 2>&1)
line=$(printf '%s\n' "$out" | python3 parse-ac180.py)
echo "$ts $line" >> ac180-poll.log
sync ac180-poll.log
sleep 10
done
The comment says "every ~10 s". The loop adds a ten-second sleep after a nine-second read, so in practice lines came 19 to 24 seconds apart. Note where the timestamp is taken: before the read. Each line describes the station at some point in the following nine seconds.
The workstation running this loop is plugged into the AC180. So the switchover test is the honest kind: pull the station's plug, and if the transfer is slower than the PSU can ride through, the log stops mid-sentence. The manual promises 20 ms or less. It didn't stop:
14:38:03 BATTERY_SOC=100% AC_INPUT_VOLTAGE=235.6V AC_INPUT_FREQUENCY=49.9Hz AC_INPUT_POWER=141W AC_OUTPUT_POWER=145W
14:38:22 BATTERY_SOC=100% AC_INPUT_VOLTAGE=236.5V AC_INPUT_FREQUENCY=49.9Hz AC_INPUT_POWER=143W AC_OUTPUT_POWER=142W
14:38:41 BATTERY_SOC=100% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=152W
14:39:00 BATTERY_SOC=100% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=148W
14:39:19 BATTERY_SOC=100% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=194W
14:39:38 BATTERY_SOC=100% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=153W
14:39:57 BATTERY_SOC=100% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=152W
14:40:16 BATTERY_SOC=99% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=149W
14:40:35 BATTERY_SOC=99% AC_INPUT_VOLTAGE=2.3V AC_INPUT_FREQUENCY=0Hz AC_INPUT_POWER=0W AC_OUTPUT_POWER=151W
14:40:54 BATTERY_SOC=99% AC_INPUT_VOLTAGE=235.1V AC_INPUT_FREQUENCY=49.9Hz AC_INPUT_POWER=669W AC_OUTPUT_POWER=132W
Voltage from 236.5 to 2.3, frequency from 49.9 to 0, input watts from 143 to 0, output carrying on at 148 to 194 W. The first read after the unplug caught it. The machine's boot time afterwards was still 2026-10-12 14:27; it never noticed it had spent a couple of minutes on a battery.
The replug that never happened#
Here is the other outage. Somewhere after the first unplug, the plug went back in and came out again. The window opens after the 14:38:22 read, which still saw grid, and closes at 14:39:26, when the second unplug was reported in chat. Three reads started inside it, at 14:38:41, 14:39:00 and 14:39:19. All three say battery. The grid came back and went away again without ever landing on a sample.
Whatever the station did in those seconds, the log has no record of it. The final replug at least shows the AC180 isn't slow to report a grid that stays: the replug was announced in chat at 14:40:48, and the read that started at 14:40:54 already showed 235.1 V. What this test can't tell apart is a blip that fell between samples and a blip the station never reported at all, which is what would happen if it waits for the grid to settle before switching back. The manual doesn't say whether it does. Either way, the log looks the same.
The sampling half of that is certain. A polling monitor sees state, never events. It answers "what is true right now" every 19 seconds, and anything shorter than that can fall between two answers. For deciding when to shut a machine down, that is fine. For keeping honest statistics on how often the grid flickers, this setup is the wrong tool, and no clever threshold will fix it.
AC input watts measure load, not grid#
The obvious "on battery" signal is AC_INPUT_POWER == 0. It is true on battery. It is also wrong as a signal, because of what the number means when the grid is up.
On grid at 100% charge, the AC180 passes power straight through to the load, and input watts track output almost exactly: 137 in / 138 out, 128 / 128, 123 / 123. Across the 22 on-grid reads, input ranged from 119 to 178 W. Then the replug line: 669 W in, 132 W out. The station was recharging, and the input figure jumped by more than 500 W while the grid did nothing different at all.
Input watts are "load plus charging". Whether the grid is present is a separate question, and voltage and frequency answer it directly. The rule I settled on: on battery if input voltage is under 50 V or input frequency is 0. The 50 V threshold is the same one nut-server-bluetti uses.
That project also reports Charging whenever input watts are above zero. By that rule, every complete on-grid line in my log would be marked charging, each one at 100%. The station was passing power through to the load.
A missing field is not zero#
One of the thirty reads came back short:
14:35:30 BATTERY_SOC=? AC_INPUT_VOLTAGE=238.3V AC_INPUT_FREQUENCY=50Hz AC_INPUT_POWER=124W AC_OUTPUT_POWER=124W
No BATTERY_SOC. The parser printed ? because it was written to; a parser that defaults a missing number to 0 would have reported an empty battery while the station sat at 100% on grid. With a shutdown rule keyed to charge, that is a clean shutdown of a healthy machine, caused by one short read.
The classifier keeps three states and refuses to guess:
import re
import sys
def grid_state(line: str) -> str:
f = dict(re.findall(r"(\w+)=([\d.]+)", line))
if "AC_INPUT_VOLTAGE" not in f or "AC_INPUT_FREQUENCY" not in f:
return "UNKNOWN"
if float(f["AC_INPUT_VOLTAGE"]) < 50 or float(f["AC_INPUT_FREQUENCY"]) == 0:
return "BATTERY"
return "GRID"
def soc(line: str) -> int | None:
m = re.search(r"BATTERY_SOC=(\d+)%", line)
return int(m.group(1)) if m else None # missing is None, never 0
for line in sys.stdin:
print(line[:8], grid_state(line), soc(line))
Run over the test log, the short read keeps its grid state and loses only its charge:
14:35:11 GRID 100
14:35:30 GRID None
14:35:49 GRID 100
nut-server-bluetti gets this part right: if any of its five fields is missing, it returns no variables at all rather than a zero. A read that fails outright, or several in a row, deserves its own alert, "station unreachable", because a Bluetooth adapter falling over looks nothing like a power cut and needs a different response.
Polling once a minute is enough for shutdown#
At about 150 W, a minute of load costs 2.5 Wh. The battery is 1152 Wh. The charge readout took from the first battery read at 14:38:41 until 14:40:16 to tick from 100 to 99. Asking once a minute instead of every 19 seconds gives up nothing that matters to a shutdown decision, and it leaves the Bluetooth link free most of the time. A poller that costs more than the thing it watches came up before, in a power monitor that had to learn to sleep.
The loop still needs the workstation awake and in Bluetooth range. Part 2 moves it onto an ESP32-S3, where the same handshake went through and the first reads after it came back as nothing but question marks.
That decision should hang on the charge level. Grid loss is the moment to start paying attention. A low battery is the moment to act. Short blips cost nothing and stay out of the log either way.
The other risk is upstream. The protocol and the keys are both reverse-engineered, so a firmware update can break monitoring without warning. Once it works, the station's firmware stays where it is.
The DJI Power 2000 is next (untested)#
The second station is a DJI Power 2000, and nothing below has touched real hardware yet. It has Wi-Fi and Bluetooth 5.0. The one cloud-based integration found reads DJI's cloud MQTT, which is no use for reporting the outage that took the internet down with it. Two community Home Assistant integrations, ha-dji-power-ble and ha-dji-power-bt, talk to it locally over BLE. Both projects say they have been tested on a Power 2000. The first needs a pair_key fetched once from the DJI account. Its protocol notes describe a "grid connection status" byte that neither project exposes yet. Whether that byte is the clean grid flag the AC180 lacks, or another number that means something else, is the next unplug test.
Filed under: linux, bluetooth, ups, power, python
Sources#
- bluetti-bt-lib README — AC180 under "Validated";
bluetti-scan/bluetti-read -e; writes unsupported on encrypted devices. - bluetti-bt-lib
encryption.py— hard-coded key constants and P-256 ECDH handshake. - bluetti-bt-lib on PyPI — version 0.1.8, uploaded 2026-08-29.
- nut-server-bluetti
bluetti_adapter.py— 50 V online threshold,Chargingon input watts, empty result on missing fields. - Official BLUETTI Home Assistant integration — supported-model list without the AC180, OAuth cloud login, runtime download of a BLE shared library.
- warhammerkid/bluetti_mqtt — older bridge, last pushed 2024-04-04, no AC180.
- BLUETTI AC180 user manual (PDF) — UPS switchover of 20 ms or less, Bluetooth-only app control.
- zuyan9/ha-dji-power-ble protocol notes — DJI Power BLE protocol,
pair_key, grid connection status byte. - hiromo/ha-dji-power-bt — second local BLE integration for DJI Power stations.
- DJI Power 2000 specs — Wi-Fi 802.11b/g/n 2.4 GHz and Bluetooth 5.0.
- trfr3ak/dji-power-home-assistant — read-only integration via DJI's cloud MQTT.