signalk-alpicool
v0.4.0
Published
Signal K plugin that monitors and controls an Alpicool (Brass Monkey / white-label K25-board) Bluetooth compressor fridge/freezer over BLE — publishes temperature, target setpoint, battery voltage and power/Eco-Max state to Signal K, and lets a Signal K c
Downloads
972
Maintainers
Readme
signalk-alpicool
A Signal K node-server plugin that monitors and controls an Alpicool Bluetooth compressor fridge/freezer (and its rebrands — Brass Monkey and other white-label coolers built on the same "K25" board) directly from the server's own Bluetooth adapter.
It reads temperature, target setpoint, battery voltage and power/Eco-Max state off the fridge and publishes them as Signal K deltas, and it lets any Signal K client set the target temperature and turn the fridge on/off over BLE.
- No extra hardware — talks to the fridge from the Pi/host BLE adapter.
- No pairing and no PIN — the Alpicool BLE protocol is unauthenticated.
- Uses the Web Bluetooth API (webbluetooth/
SimpleBLE — BlueZ over DBus on Linux), so it works inside a Dockerised Signal K
server that mounts
/run/dbus+/var/run/dbus(no raw HCI socket /CAP_NET_RAW/ root needed).
What it publishes
SI units, emitted as deltas (with units meta). Paths use configurable prefixes
(refrigerator default environment.inside.refrigerator, freezer default
environment.inside.freezer) and a battery id (default fridge):
| Signal K path | Units | Meaning |
|---|---|---|
| environment.inside.refrigerator.temperature | K | Measured temperature — warmer zone |
| environment.inside.refrigerator.targetTemperature | K | Setpoint, warmer zone (settable — see Control) |
| environment.inside.freezer.temperature | K | Measured temperature — colder zone (dual-zone units) |
| environment.inside.freezer.targetTemperature | K | Setpoint, colder zone (settable) |
| environment.inside.refrigerator.state | — | on / off (soft power state; settable) |
| environment.inside.refrigerator.mode | — | eco / max (run mode) |
| electrical.batteries.fridge.voltage | V | Input/battery voltage the fridge senses |
| electrical.batteries.fridge.capacity.stateOfCharge | ratio | Battery charge (0–1) as reported by the fridge |
Dual-zone units publish both zones. Zones are labelled by value, not by physical side: the lower-setpoint zone is the freezer, the higher the refrigerator — so the paths stay meaningful whichever way round the unit numbers its compartments. Single-zone units publish only the refrigerator paths.
The fridge reports whole degrees in its own display unit (°C or °F); the plugin converts to Kelvin for Signal K. The unit is read from the fridge's status; a fallback is set in config until the first status arrives.
Control (PUT)
When Enable control is on, the plugin registers PUT handlers so a Signal K client can write the fridge over BLE:
refrigerator.targetTemperature/freezer.targetTemperature— PUT a value in Kelvin → the plugin converts to the fridge's unit and sends a Set-Target frame. Each label is mapped back to the correct physical zone (BLE command0x05/0x06) from the current status, so setting "freezer" always hits the colder compartment.….state— PUTon/off(ortrue/false) → the plugin sends a bulk Set frame (BLE command0x02) that flips soft power without disturbing other settings.
Example (Signal K PUT over HTTP):
PUT /signalk/v1/api/vessels/self/environment/inside/refrigerator/targetTemperature
{ "value": 277.15 } # 4 °CWith control disabled the plugin is strictly read-only.
Resilience & plugin status
BLE links to these fridges fail in three distinct ways, and the plugin defends against all of them:
- The link drops (fridge out of range, adapter reset, …) — the transport reconnects with exponential backoff (3 s → 60 s), retrying the connect around the fridge's intermittent advertising, with every scan/connect/GATT call under a timeout so a hung BLE promise can never wedge the plugin.
- The link stays up but goes silent — the socket looks connected while the fridge has stopped answering. A data watchdog guards this: the plugin records when every status frame arrives, and if nothing lands for 3× the poll interval (the extra beyond one interval is capped at 5 minutes, so a long poll interval can't quietly disable the protection) it declares the link dead regardless of socket state, forces a disconnect and lets the reconnect loop recover. After 3 forced reconnects with no data in between the plugin goes into an error state so the problem is visible on the server dashboard; it returns to normal as soon as a reading arrives.
- A previous run left the link open — BlueZ runs on the host and outlives the plugin
process (especially in Docker, where it is reached over the mounted D-Bus socket). If
the process is killed rather than stopped cleanly, BlueZ keeps holding the fridge's
single BLE connection — and a connected device does not advertise, so every scan after
that times out forever while
bluetoothctl infoshowsConnected: yes. The plugin self-heals: before every scan it asks BlueZ directly (over D-Bus, no extra dependencies) whether the fridge is already connected on this host, releases the orphaned link, and says so in the status.plugin.stop()also releases the link deterministically, so an ordinary restart never orphans it in the first place. This is safe by construction: BlueZ only knows links made through this host's own adapter, so the self-heal can never kick the owner's phone off the fridge.
The plugin status line (Server → Plugin Config / Dashboard) reports data freshness, not
just socket state — e.g. Connected — last reading 30s ago, Connected but silent for
2min — forced reconnect (1/3), or Disconnected — reconnecting (last reading 5min ago).
A healthy-looking status can therefore be trusted: it goes stale when the data does.
The fridge accepts only ONE Bluetooth connection
This is a hardware constraint, not a plugin setting: while the Alpicool phone app (or any other device) is connected, the plugin cannot connect at all — and vice versa. When connect attempts keep failing, the plugin status says so explicitly and suggests closing the phone app. If readings stop while everything looks fine, the phone app being open nearby is the first thing to check.
The same constraint has a second face: a stale link on the Signal K host itself
(orphaned by a killed process — see above). The two are distinguishable and the status
names each one: a host-side link shows Connected: yes in bluetoothctl info <MAC> and
is released automatically by the self-heal; a phone holding the fridge is invisible to
the host's BlueZ and simply makes every scan and connect fail until the app is closed.
If the self-heal ever cannot release a host-side link, the status says so and gives the
manual fallback: bluetoothctl disconnect <MAC>.
Finding the fridge's BLE MAC address
The fridge advertises a BLE name of WT-0001, or one starting with A1- / AK1- /
AK2- / AK3-. On the Signal K host (e.g. a Raspberry Pi):
bluetoothctl
scan on # watch for WT-0001 / A1-* / AK* and note its MAC
scan off
exitPut that MAC (e.g. E4:5F:01:AA:BB:CC) into the plugin config. You do not need to
pair or trust the device — the plugin connects directly.
Install
Install the supported way — via the Signal K Appstore (search "alpicool"), or with npm into your Signal K server:
npm install signalk-alpicoolThen enable and configure it in Server → Plugin Config. Do not hand-copy the plugin
into node_modules. See INSTALL.md for Docker/DBus specifics and troubleshooting.
Configuration
| Setting | Default | Notes |
|---|---|---|
| Fridge BLE MAC address | — | Required. AA:BB:CC:DD:EE:FF. |
| Poll interval (seconds) | 60 | How often to query status (BLE 0x01). Min 5. Fridge temps move slowly — once a minute is plenty, and fewer polls mean less traffic on the fridge's single BLE connection. |
| Signal K path prefix — refrigerator (warmer zone) | environment.inside.refrigerator | Base for the warmer zone + state/mode. |
| Signal K path prefix — freezer (colder zone) | environment.inside.freezer | Dual-zone units only; base for the colder zone. |
| Battery id | fridge | electrical.batteries.<id> for voltage + state of charge. |
| Fridge temperature unit (fallback) | C | Used until the first status reveals the real unit. |
| Enable control (PUT → BLE) | off | Turn on to allow setting target/power over BLE. |
Protocol
The Alpicool/Brass Monkey GATT protocol is a BLE-to-serial bridge: service 0x1234, write
characteristic 0x1235, notify characteristic 0x1236; FE FE-framed binary commands with
a 16-bit sum checksum. The codec in lib/protocol.js is ported from
klightspeed/BrassMonkeyFridgeMonitor
(Python) and cross-checked against johnelliott/alpicoold
(Go). The codec has deterministic unit tests (npm test, no Bluetooth required).
Status
Verified live on a dual-zone Alpicool: reads both zones + battery, and control (set
target on either zone, power on/off) works end-to-end over BLE. Codec has deterministic
unit tests (npm test, no Bluetooth). See the protocol gotchas below.
License
MIT — see LICENSE.
Protocol gotchas (verified on real hardware, 2026-07-20)
Three things are essential and easy to miss (all cost real debugging time):
- BIND is a fallback, not a handshake. Some units answer QUERY straight away with
no BIND (
0x00) ever sent; others stay silent until one is accepted (the display showsAPPand you press the pairing button once). Sending BIND unconditionally on every connect nags the owner at the fridge on each reconnect — so this plugin polls first and only sends a single BIND if no status arrives within 6 s. (Ref: Gruni22/alpicool_ha_ble; fallback behaviour verified on a real K25.) - Write-without-response. The write characteristic
0x1235only permits write-without-response (command). Using write-with-response (request) fails withWrite not permittedand every poll silently drops. - Fragmented + concatenated notifications. A status arrives split across BLE
notifications at the ATT MTU, and a single notification can concatenate an echo +
a status. Reassemble by the
FE FE <len>frame length (this plugin does).
Verified live on a dual-zone Alpicool (BLE name A1-…): temp/target/battery/state all
decode and publish to Signal K.
