Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,21 @@ name and the entity's name:
|---|---|---|
| `BMS_1` | Total voltage | `sensor.bms_1_total_voltage` |
| `BMS_1` | State of charge | `sensor.bms_1_state_of_charge` |
| `BMS_1` | Maximum charge current | `sensor.bms_1_maximum_charge_current` (`number.…` when the safety tier is on) |
| `BMS_1` | Maximum charge current | `sensor.bms_1_maximum_charge_current` |
| `BMS_1` | Maximum charge current control | `number.bms_1_maximum_charge_current_control` (only while the safety tier is on) |
| `BMS_1` | Cell 1 resistance | `sensor.bms_1_cell_1_resistance` |

This is Home Assistant's [documented convention](https://developers.home-assistant.io/docs/core/entity/#entity-naming);
HA core warns that "in most cases, entities should not set entity_id". Builds
before 2.2.0 did override it — first with `object_id` (removed by HA in 2026.4)
and then with `default_entity_id` — so older installs carry different ids.

Since 2.4.0 a setting is two entities: the read-only twin above, which always
exists, and a `…_control` entity that exists only while its write tier is on.
Toggling a tier therefore adds or removes the control and leaves the twin's id
alone. Before 2.4.0 the setting itself changed component with the tier, so its
id changed too — which silently broke anything referencing it.

**Home Assistant never renames an entity it has already registered.** An
install that ran an earlier build therefore keeps its old ids, and the
generated dashboard will not resolve them. `scripts/rename_entities.py`
Expand Down
5 changes: 3 additions & 2 deletions dashboards/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ entity's `name`:

`sensor.bms_1_total_voltage`, `sensor.bms_1_state_of_charge`,
`sensor.bms_1_cell_1_resistance`, `sensor.bms_1_maximum_charge_current`
(`number.…` once its write tier is on).
(plus `number.bms_1_maximum_charge_current_control` once its write tier is on).

The generator computes the same slugs from the entity table (`SLUG` in
`dashboard.py`), so generator and bridge cannot drift: renaming an entity moves
Expand Down Expand Up @@ -73,7 +73,8 @@ the core gauge).
`enable_basic_writes` / `enable_safety_writes` are on; otherwise as read-only
`sensor`/`binary_sensor`. The generator takes the tiers (`--basic-writes` /
`--safety-writes`; the add-on passes its options) and renders each Controls row
in the matching domain, so settings stay visible read-only when a tier is off.
pointing at the `…_control` entity when a tier is on and at the permanent
read-only twin otherwise, so a setting is always visible either way.
On legacy installs the read-only variants use the `device_` id form, like
`last_seen` (verified against a legacy install's BMS_1 entity dump, add-on
2.1.2).
Expand Down
7 changes: 4 additions & 3 deletions dashboards/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ and **Diagnostics** + **Nameplate**:
![Pack detail — Live, Cells, Diagnostics](docs/bms_1a.png)

Scrolling down: **Controls** (every BMS setting with its current value —
editable `number`/`switch` rows when the add-on's write tier is on, read-only
rows otherwise) and **History** graphs:
the editable `…_control` row when the add-on's write tier is on, the read-only
row otherwise) and **History** graphs:

![Pack detail — Controls (basic) and History](docs/bms_1b.png)
![Pack detail — Safety thresholds and Temperatures history](docs/bms_1c.png)
Expand Down Expand Up @@ -84,7 +84,8 @@ python dashboards/generate.py --bms-ids 1,2,3,4,5,6 --cells 16 --basic-writes --

The tier flags must match `enable_basic_writes` / `enable_safety_writes` in the
add-on options (both off by default), because a setting is a `number`/`switch`
only while its tier is on and a `sensor`/`binary_sensor` otherwise.
a `…_control` entity only while its tier is on, alongside a read-only
`sensor`/`binary_sensor` that is always there.

Requires Python 3 + PyYAML (`pip install pyyaml`) — a generator-time tool only,
nothing extra runs in the add-on. Outputs:
Expand Down
4 changes: 2 additions & 2 deletions dashboards/TESTPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ Tap a pack heading → its `BMS n` subview opens.
| # | Check | Pass criteria |
|---|---|---|
| 6.1 | Enable basic writes | Set `enable_basic_writes: true`, restart add-on (manual dashboard: regenerate with `--basic-writes` and re-paste) |
| 6.2 | Control appears | A basic row becomes an editable `number`/`switch` in Controls |
| 6.2 | Control appears | A basic setting gains an editable `…_control` `number`/`switch`; its read-only twin keeps the same entity id |
| 6.3 | Round-trip | Nudge a safe value (e.g. balance trigger), confirm it sticks and reads back; revert it |
| 6.4 | Gating | Safety rows stay read-only (values shown, not editable) while `enable_safety_writes` is off |
| 6.5 | Disable again | Set `enable_basic_writes: false`, restart: basic rows are read-only again, no Unavailable rows, no leftover `number`/`switch` entities |
| 6.5 | Disable again | Set `enable_basic_writes: false`, restart: the `…_control` entities are gone, the read-only twins keep their ids and values, no Unavailable rows |

## Results

Expand Down
40 changes: 33 additions & 7 deletions dashboards/check_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
LIVE_BINARY_SENSORS,
LIVE_SENSORS,
WRITABLE_ENTITIES,
control_object_id,
expand_cell_entities,
writable_component,
)
Expand Down Expand Up @@ -78,13 +79,36 @@ def bridge_entities(*, basic_writes: bool, safety_writes: bool) -> set[tuple[str
out.add((e.component.value, e.object_id))
for w in WRITABLE_ENTITIES:
if w.verified:
writable = generate.tier_enabled(
is_bool = w.register.encoding is Encoding.BOOL32
# The read-only twin exists whatever the tier.
out.add((writable_component(is_bool=is_bool, writable=False).value, w.object_id))
if generate.tier_enabled(
w.object_id, basic_writes=basic_writes, safety_writes=safety_writes
)
component = writable_component(
is_bool=w.register.encoding is Encoding.BOOL32, writable=writable
)
out.add((component.value, w.object_id))
):
out.add(
(
writable_component(is_bool=is_bool, writable=True).value,
control_object_id(w.object_id),
)
)
return out


def hidden_twins(*, basic_writes: bool, safety_writes: bool) -> set[tuple[str, str]]:
"""Read-only twins the dashboard deliberately does not show.

With a write tier on, a setting has both entities, but the dashboard shows
only the control — the row the user can act on. The twin is still
published, so exclude it from the coverage check rather than reporting it
as drift.
"""
out: set[tuple[str, str]] = set()
for w in WRITABLE_ENTITIES:
if w.verified and generate.tier_enabled(
w.object_id, basic_writes=basic_writes, safety_writes=safety_writes
):
is_bool = w.register.encoding is Encoding.BOOL32
out.add((writable_component(is_bool=is_bool, writable=False).value, w.object_id))
return out


Expand Down Expand Up @@ -115,7 +139,9 @@ def _check(*, basic_writes: bool, safety_writes: bool) -> bool:
bridge = bridge_entities(**tiers)
dash = dashboard_entities(**tiers)

missing = sorted(bridge - dash - ALLOW_MISSING) # bridge has, dashboard lacks
missing = sorted(
bridge - dash - ALLOW_MISSING - hidden_twins(**tiers)
) # bridge has, dashboard lacks
unknown = sorted(dash - bridge) # dashboard refs, bridge doesn't publish

if not missing and not unknown:
Expand Down
25 changes: 18 additions & 7 deletions jkbms2mqtt/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ Every BMS setting is **always visible** in Home Assistant as a status sensor
showing its current value from the BMS. The tier toggles only control whether
those entities are *writable*:

- `enable_basic_writes: true` upgrades the basic-tier entities to `number` /
`switch` controls (charge / discharge / balance switches, balance
- `enable_basic_writes: true` adds a `number` / `switch` **control** next to
each basic-tier setting (charge / discharge / balance switches, balance
thresholds, smart-sleep voltage, etc.).
- `enable_safety_writes: true` upgrades the safety-tier entities (OVP / UVP,
- `enable_safety_writes: true` does the same for the safety-tier settings (OVP / UVP,
max charge / discharge current, OCP delays, OTP / UTP thresholds, cell
count). **A wrong value here can damage the cells or pose a fire risk** —
leave off unless you know exactly what you're changing.
Expand All @@ -177,12 +177,23 @@ structured JSON error on `<bms_name>/error`. This is the default safe state:
HA users can see what every setting is, but cannot accidentally change a
safety-critical threshold.

Changing a tier needs an add-on restart. On that restart the setting moves
between `sensor` and `number` (or `binary_sensor` and `switch`): the bridge
removes the old entity from Home Assistant and the auto-installed dashboard
switches its rows to match. A manually generated dashboard must be regenerated
Every setting is published as **two** entities, so its id never changes:

| | Entity | Exists |
|---|---|---|
| Read-only twin | `sensor.bms_1_maximum_charge_current` | always |
| Control | `number.bms_1_maximum_charge_current_control` | only while its tier is on |

The twin always shows the BMS's current value; the control is what you edit.
Changing a tier needs an add-on restart, after which the control appears or
disappears while the twin — and anything referencing it — is untouched. The
auto-installed dashboard follows; a manually generated one must be regenerated
with the matching `--basic-writes` / `--safety-writes` flags.

Home Assistant cannot render a read-only `number` or `switch` (`command_topic`
is required for both), which is why the control is a separate entity rather
than the same one changing shape.

Example rejected write (basic tier off):

```json
Expand Down
2 changes: 1 addition & 1 deletion jkbms2mqtt/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: jkbms2mqtt
version: "2.3.0"
version: "2.4.0"
slug: jkbms2mqtt
description: JK-BMS to MQTT bridge using the standard RS485 Modbus V1.0 / V1.1 protocol
url: https://github.com/basmeerman/jkbms2mqtt
Expand Down
27 changes: 15 additions & 12 deletions jkbms2mqtt/docs/ENTITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ device page sorts it into one of four sections:
|---|---|---|
| **Sensors** | (none) | Primary read-only telemetry — the values you check on the device page every day |
| **Controls** | (none) | A writable entity with no category. Currently empty: the charging / discharging / balance switches are spec-defined configuration parameters (they tune device behaviour rather than being the pack's main power switch), so they land under **Configuration** instead. |
| **Configuration** | `config` | Every writable setting + every packed-bit mode toggle while its write tier is on; settable thresholds, current limits, OTP / UTP thresholds, etc. |
| **Configuration** | `config` | The `…_control` entity of every setting and packed-bit toggle, published while its write tier is on; settable thresholds, current limits, OTP / UTP thresholds, etc. |
| **Diagnostics** | `diagnostic` | Read-only debug / lifetime / static info: model, hw, sw, serial number, cycle count, cycle capacity, runtime, SoH, raw alarm bitmap, present cell count, per-cell internal resistances. Also every setting whose write tier is off: it is then a sensor, and Home Assistant does not allow `config` on sensors. |

When a tier is toggled, a setting moves between `sensor` and `number` (or
Expand Down Expand Up @@ -83,10 +83,13 @@ the HA name. The names and the ids they produce are listed here, for `BMS_1`:
| **Bridge** | | |
| `last_seen` | Last seen | `sensor.bms_1_last_seen` |

Settings take the same ids; only the domain follows the write tier
(`sensor` / `binary_sensor` when the tier is off, `number` / `switch` when on):
Each setting is published twice: a read-only twin that always exists, and a
control that exists only while its write tier is on. The control's object id
and name carry a `control` suffix, so it gets its own entity id — e.g.
`number.bms_1_maximum_charge_current_control` next to
`sensor.bms_1_maximum_charge_current`. The read-only ids below never change:

| Object id | Entity name | Entity id (tier off) |
| Object id | Entity name | Entity id (read-only twin) |
|---|---|---|
| `smart_sleep_voltage` | Smart sleep voltage | `sensor.bms_1_smart_sleep_voltage` |
| `balance_trigger_voltage` | Balance trigger voltage | `sensor.bms_1_balance_trigger_voltage` |
Expand Down Expand Up @@ -128,8 +131,8 @@ Settings take the same ids; only the domain follows the write tier
| State | Meaning |
|---|---|
| **Read-only** | Published as an HA sensor / binary sensor. Cannot be changed from HA. |
| **Read / write (basic)** | Always visible as a status sensor. Becomes a writable `number` / `switch` entity when `enable_basic_writes: true`. |
| **Read / write (safety)** | Always visible as a status sensor. Becomes a writable `number` / `switch` entity when `enable_safety_writes: true`. |
| **Read / write (basic)** | Always visible as a status sensor. Gains a `…_control` `number` / `switch` entity when `enable_basic_writes: true`. |
| **Read / write (safety)** | Always visible as a status sensor. Gains a `…_control` `number` / `switch` entity when `enable_safety_writes: true`. |
| **Unverified** | Modbus register offset has not been confirmed against real hardware. Hidden by default; set `debug_unverified_fields: true` to surface them for experimentation. Marked ⚠ below. |

A write to a parameter whose tier is disabled is rejected with a structured
Expand Down Expand Up @@ -233,8 +236,8 @@ value changes, so it cannot tell a steady reading from a stale one.

## Read / write — basic tier

All entries land in HA's **Configuration** section (`entity_category: config`)
when the tier is on, and in **Diagnostics** when it is off.
The read-only twins land in HA's **Diagnostics** section; their `…_control`
entities, published while the tier is on, land in **Configuration**.
All addresses calibrated against spec V1.1 and verified against
`scripts/captures/BMS_1.txt`. Visible as `number` / `switch` when
`enable_basic_writes: true`, as `sensor` / `binary_sensor` otherwise (current
Expand All @@ -259,8 +262,8 @@ being the device's main on/off switch.

### Unverified packed-bit toggles (basic, hidden by default)

In HA's **Configuration** section (`entity_category: config`) when the tier
is on, **Diagnostics** when it is off. The
Read-only twins in **Diagnostics**; their `…_control` entities in
**Configuration** (`entity_category: config`) while the tier is on. The
packed-bit register at `0x1114` holds several boolean flags but the bit
positions are not yet confirmed. Marked `verified=False`; visible only when
`debug_unverified_fields: true`.
Expand All @@ -273,8 +276,8 @@ positions are not yet confirmed. Marked `verified=False`; visible only when

## Read / write — safety tier

All entries land in HA's **Configuration** section (`entity_category: config`)
when the tier is on, and in **Diagnostics** when it is off.
The read-only twins land in HA's **Diagnostics** section; their `…_control`
entities, published while the tier is on, land in **Configuration**.
All addresses and encodings verified against `scripts/captures/BMS_1.txt` and
the BMS app screenshots. Visible as `number` when `enable_safety_writes: true`,
as `sensor` otherwise.
Expand Down
16 changes: 13 additions & 3 deletions jkbms2mqtt/scripts/rename_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
LIVE_SENSORS,
PACKED_BIT_ENTITIES,
WRITABLE_ENTITIES,
control_name,
control_object_id,
expand_cell_entities,
writable_component,
)
Expand Down Expand Up @@ -95,12 +97,20 @@ def target_object_ids() -> dict[str, tuple[str, str]]:
):
out[e.object_id] = (e.component.value, ha_slugify(e.description.rstrip(".")))
for w in WRITABLE_ENTITIES:
component = writable_component(
is_bool=w.register.encoding is Encoding.BOOL32, writable=False
is_bool = w.register.encoding is Encoding.BOOL32
read_only = writable_component(is_bool=is_bool, writable=False)
out[w.object_id] = (read_only.value, ha_slugify(w.description.rstrip(".")))
# The control twin, published while the setting's write tier is on.
out[control_object_id(w.object_id)] = (
writable_component(is_bool=is_bool, writable=True).value,
ha_slugify(control_name(w.description.rstrip("."))),
)
out[w.object_id] = (component.value, ha_slugify(w.description.rstrip(".")))
for p in PACKED_BIT_ENTITIES:
out[p.object_id] = ("binary_sensor", ha_slugify(p.bit.description.rstrip(".")))
out[control_object_id(p.object_id)] = (
"switch",
ha_slugify(control_name(p.bit.description.rstrip("."))),
)
return out


Expand Down
35 changes: 26 additions & 9 deletions jkbms2mqtt/src/jkbms2mqtt/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
LIVE_SENSORS,
PACKED_BIT_ENTITIES,
WRITABLE_ENTITIES,
control_name,
control_object_id,
expand_cell_entities,
writable_component,
)
Expand Down Expand Up @@ -69,6 +71,20 @@ def _slug_table() -> dict[str, str]:
table = {e.object_id: _ha_slugify(e.description) for e in read_only}
table.update({w.object_id: _ha_slugify(w.description) for w in WRITABLE_ENTITIES})
table.update({p.object_id: _ha_slugify(p.bit.description) for p in PACKED_BIT_ENTITIES})
# Each setting also has a control twin, published while its write tier is
# on, with its own name and therefore its own entity id.
table.update(
{
control_object_id(w.object_id): _ha_slugify(control_name(w.description))
for w in WRITABLE_ENTITIES
}
)
table.update(
{
control_object_id(p.object_id): _ha_slugify(control_name(p.bit.description))
for p in PACKED_BIT_ENTITIES
}
)
return table


Expand Down Expand Up @@ -97,18 +113,19 @@ def tier_enabled(object_id: str, *, basic_writes: bool, safety_writes: bool) ->


def setting(n: int, object_id: str, *, writable: bool) -> str:
"""Entity id of a writable setting, whose domain follows its write tier.

Tier on: ``number`` / ``switch``. Tier off: the bridge publishes the same
object_id read-only as ``sensor`` / ``binary_sensor``.
"""Entity id to put on the dashboard for a writable setting.

The entity name is the same either way, so only the domain changes.
The bridge publishes two entities per setting: a read-only ``sensor`` /
``binary_sensor`` that always exists, and a ``number`` / ``switch``
control that exists only while the write tier is on. Show whichever the
user can act on: the control when the tier is on, the read-only twin
otherwise.
"""
w = _WRITABLES[object_id]
domain = writable_component(
is_bool=w.register.encoding is Encoding.BOOL32, writable=writable
).value
return ent(domain, n, object_id)
is_bool = w.register.encoding is Encoding.BOOL32
domain = writable_component(is_bool=is_bool, writable=writable).value
key = control_object_id(object_id) if writable else object_id
return ent(domain, n, key)


# Writable params, in dashboard order. Each renders through ``setting()``, so
Expand Down
Loading