From 31a6f2d32e297a57c94a8485355f4b7212c3de15 Mon Sep 17 00:00:00 2001 From: magicx78 <66534521+magicx78@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:19:57 +0200 Subject: [PATCH 1/2] feat(rmm): Radar Map Manager compatibility (additive) Make the integration work with Moe8383/radar_map_manager, which reads each radar by entity-id convention sensor._target__x/_y (mm). - Enable the per-target X/Y sensors by default (RMM reads them; mm is used as-is). New `none_when_absent` flag makes empty target slots report `unknown` so RMM skips them cleanly instead of seeing (0,0). - Add `presence_target_count` sensor (0..3 currently present targets). - README: RMM section incl. the locale caveat (HA builds the entity-id suffix from the translated entity name, so non-English installs must rename the X/Y entity-ids to the literal `_target__x/_y` form). - Add tests/test_sensor.py (X/Y enabled + mm + none-when-absent, count). No BLE changes, no renamed/removed entities, no breaking changes. Verified end-to-end on real HLK-LD2450 hardware: RMM's FusionEngine reads the live coordinates and fuses them into its master sensor. Co-Authored-By: Claude Opus 4.8 --- README.md | 70 +++++++++++++- custom_components/ld2450_ble/sensor.py | 38 +++++++- custom_components/ld2450_ble/strings.json | 1 + .../ld2450_ble/translations/de.json | 1 + .../ld2450_ble/translations/en.json | 1 + tests/test_sensor.py | 94 +++++++++++++++++++ 6 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 tests/test_sensor.py diff --git a/README.md b/README.md index 24df118..f66114f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Created per device (target `N` = 1–3): | `binary_sensor` | Target N moving | Movement per target | | `sensor` | Target N distance | mm, enabled by default | | `sensor` | Target N angle | °, enabled by default | -| `sensor` | Target N X / Y | mm, disabled by default | +| `sensor` | Target N X / Y | mm, enabled by default — unknown when the slot is empty | +| `sensor` | Presence target count | 0–3 currently present targets, enabled by default | | `sensor` | Target N speed | mm/s, disabled by default | | `sensor` | Target N resolution | mm, disabled by default | | `switch` | Multi-target tracking | On = multi, off = single | @@ -74,6 +75,73 @@ drop the state is **reconnecting** while the library retries, then > implemented yet — a correct version needs persistent 24 h history > (recorder/restore) rather than an in-memory value that resets on restart. +## Radar Map Manager (RMM) support + +This integration is compatible with +[Moe8383/radar_map_manager](https://github.com/Moe8383/radar_map_manager), which +visualises radar targets on a floor-plan map. + +RMM reads each radar purely by **entity-id convention**: + +``` +sensor._target_1_x +sensor._target_1_y +sensor._target_2_x +sensor._target_2_y +sensor._target_3_x +sensor._target_3_y +``` + +The `Target N X / Y` sensors above already provide exactly these — in +**millimetres**, which RMM uses as-is (it reads `unit_of_measurement`; `m` and +`cm` are scaled, `mm` is taken directly). They are **enabled by default**, and an +empty target slot reports `unknown`, which RMM skips cleanly. No extra +configuration, helper templates or duplicate sensors are required. + +### Matching the radar name (and a language caveat) + +RMM looks up the literal entity-ids `sensor._target__x` / `_y`, where +`` is the name you pass to RMM. Home Assistant generates an entity-id from +the device name **and the entity name in your configured language**, so both +parts must line up: + +- **Prefix** = the device-name slug (e.g. device `HLK-LD2450 1B6F` → + `sensor.hlk_ld2450_1b6f_…`). +- **Suffix** = `target__x`. This is the English entity name. **If your Home + Assistant runs in another language the suffix is translated** — e.g. in German + it becomes `…_ziel_1_x`, which RMM will *not* find. + +So for RMM, make sure the X/Y entity-ids end in `_target__x` / `_y`: + +- **English HA:** already correct out of the box — just give the device a clean + name (e.g. `rd_ld2450`). +- **Non-English HA:** rename the six X/Y **entity-ids** (not just the device) to + the `target_` form under **Settings → Devices & Services → LD2450 BLE → + device → the entity → ⚙ → Entity ID**. + +Either way the target entity-ids become: + +``` +sensor.rd_ld2450_target_1_x +sensor.rd_ld2450_target_1_y +sensor.rd_ld2450_presence_target_count +``` + +### Registering in RMM + +Call the RMM service with the matching name: + +```yaml +service: radar_map_manager.add_radar +data: + radar_name: rd_ld2450 + map_group: default +``` + +> `presence_target_count` (0–3) is provided as a convenience and for dashboards. +> RMM computes its own fused target count across radars +> (`sensor.rmm__master`), so it does not consume this sensor directly. + ## Installation ### HACS (custom repository) diff --git a/custom_components/ld2450_ble/sensor.py b/custom_components/ld2450_ble/sensor.py index e0e9548..1b5e9f7 100644 --- a/custom_components/ld2450_ble/sensor.py +++ b/custom_components/ld2450_ble/sensor.py @@ -30,6 +30,10 @@ class LD2450BLESensorEntityDescription(SensorEntityDescription): """Describes an LD2450 sensor derived from a target.""" value_fn: Callable[[Target], float | int] + # When True, report ``None`` (unknown) instead of a value for an empty + # target slot. Used by the X/Y coordinates so downstream consumers (e.g. + # Radar Map Manager) cleanly skip absent targets instead of seeing (0, 0). + none_when_absent: bool = False SENSOR_TYPES: tuple[LD2450BLESensorEntityDescription, ...] = ( @@ -50,13 +54,15 @@ class LD2450BLESensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, value_fn=lambda t: round(t.angle, 1), ), + # X/Y are enabled by default: they are the coordinates Radar Map Manager + # reads (sensor._target_N_x / _y, in mm). Empty slots report unknown. LD2450BLESensorEntityDescription( key="x", translation_key="x", native_unit_of_measurement=UnitOfLength.MILLIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + none_when_absent=True, value_fn=lambda t: t.x, ), LD2450BLESensorEntityDescription( @@ -65,7 +71,7 @@ class LD2450BLESensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfLength.MILLIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + none_when_absent=True, value_fn=lambda t: t.y, ), LD2450BLESensorEntityDescription( @@ -164,6 +170,7 @@ async def async_setup_entry( LD2450BLEDiagnosticSensor(coordinator, description) for description in DIAGNOSTIC_SENSOR_TYPES ) + entities.append(LD2450BLEPresenceCountSensor(coordinator)) async_add_entities(entities) @@ -185,9 +192,11 @@ def __init__( self._attr_translation_placeholders = {"target": str(index + 1)} @property - def native_value(self) -> float | int: + def native_value(self) -> float | int | None: """Return the current value for this target field.""" target = self.coordinator.device.state.targets[self._index] + if self.entity_description.none_when_absent and not target.present: + return None return self.entity_description.value_fn(target) @@ -209,3 +218,26 @@ def __init__( def native_value(self) -> datetime | int | str | None: """Return the current diagnostic value.""" return self.entity_description.value_fn(self.coordinator) + + +class LD2450BLEPresenceCountSensor(LD2450BLEEntity, SensorEntity): + """Number of currently present targets (0..3). + + Exposed as ``sensor._presence_target_count`` for convenience and + Radar Map Manager compatibility. (RMM derives its own fused count, so this + is an additive helper, not a hard requirement.) + """ + + _attr_translation_key = "presence_target_count" + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator: LD2450BLECoordinator) -> None: + """Initialise the presence-count sensor.""" + super().__init__(coordinator, "presence_target_count") + + @property + def native_value(self) -> int: + """Return how many target slots currently hold a real detection.""" + return sum( + 1 for target in self.coordinator.device.state.targets if target.present + ) diff --git a/custom_components/ld2450_ble/strings.json b/custom_components/ld2450_ble/strings.json index 0f22c28..ab259a9 100644 --- a/custom_components/ld2450_ble/strings.json +++ b/custom_components/ld2450_ble/strings.json @@ -34,6 +34,7 @@ "y": { "name": "Target {target} Y" }, "speed": { "name": "Target {target} speed" }, "resolution": { "name": "Target {target} resolution" }, + "presence_target_count": { "name": "Presence target count" }, "last_seen": { "name": "Last seen" }, "last_disconnect": { "name": "Last disconnect" }, "disconnect_count": { "name": "Disconnect count" }, diff --git a/custom_components/ld2450_ble/translations/de.json b/custom_components/ld2450_ble/translations/de.json index 613090a..e72bc0c 100644 --- a/custom_components/ld2450_ble/translations/de.json +++ b/custom_components/ld2450_ble/translations/de.json @@ -34,6 +34,7 @@ "y": { "name": "Ziel {target} Y" }, "speed": { "name": "Ziel {target} Geschwindigkeit" }, "resolution": { "name": "Ziel {target} Auflösung" }, + "presence_target_count": { "name": "Anzahl anwesender Ziele" }, "last_seen": { "name": "Zuletzt gesehen" }, "last_disconnect": { "name": "Letzte Trennung" }, "disconnect_count": { "name": "Trennungen" }, diff --git a/custom_components/ld2450_ble/translations/en.json b/custom_components/ld2450_ble/translations/en.json index 0f22c28..ab259a9 100644 --- a/custom_components/ld2450_ble/translations/en.json +++ b/custom_components/ld2450_ble/translations/en.json @@ -34,6 +34,7 @@ "y": { "name": "Target {target} Y" }, "speed": { "name": "Target {target} speed" }, "resolution": { "name": "Target {target} resolution" }, + "presence_target_count": { "name": "Presence target count" }, "last_seen": { "name": "Last seen" }, "last_disconnect": { "name": "Last disconnect" }, "disconnect_count": { "name": "Disconnect count" }, diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..ddbca4e --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,94 @@ +"""Unit tests for the RMM-compatible sensor behaviour. + +These exercise the pure ``native_value`` logic of the X/Y coordinate sensors +and the presence-count sensor without spinning up Home Assistant, mirroring the +lightweight style of ``test_models.py``. +""" + +from __future__ import annotations + +import types + +from homeassistant.const import UnitOfLength + +from custom_components.ld2450_ble.ld2450_ble.models import LD2450BLEState, Target +from custom_components.ld2450_ble.sensor import ( + SENSOR_TYPES, + LD2450BLEPresenceCountSensor, + LD2450BLESensor, +) + + +def _desc(key: str): + """Return the sensor description with the given key.""" + return next(d for d in SENSOR_TYPES if d.key == key) + + +def _state(*targets: Target) -> LD2450BLEState: + """Build a 3-slot state, padding missing slots with empty targets.""" + slots = list(targets) + [Target() for _ in range(3 - len(targets))] + return LD2450BLEState(targets=tuple(slots)) + + +def _fake_coordinator(state: LD2450BLEState): + """A minimal stand-in exposing ``coordinator.device.state``.""" + return types.SimpleNamespace(device=types.SimpleNamespace(state=state)) + + +def _target_sensor(key: str, index: int, state: LD2450BLEState) -> LD2450BLESensor: + """Build an LD2450BLESensor without the HA entity machinery.""" + sensor = LD2450BLESensor.__new__(LD2450BLESensor) + sensor.entity_description = _desc(key) + sensor._index = index + sensor.coordinator = _fake_coordinator(state) + return sensor + + +def test_xy_enabled_by_default() -> None: + """X/Y must be enabled by default and flagged none-when-absent for RMM.""" + for key in ("x", "y"): + desc = _desc(key) + assert desc.entity_registry_enabled_default is not False + assert desc.none_when_absent is True + + +def test_xy_unit_is_millimetres() -> None: + """RMM consumes mm as-is; the unit must stay millimetres.""" + for key in ("x", "y"): + assert _desc(key).native_unit_of_measurement == UnitOfLength.MILLIMETERS + + +def test_xy_present_returns_coordinate() -> None: + """A present target reports its raw mm coordinates.""" + state = _state(Target(x=-782, y=1100, speed=-236)) + assert _target_sensor("x", 0, state).native_value == -782 + assert _target_sensor("y", 0, state).native_value == 1100 + + +def test_xy_absent_returns_none() -> None: + """Empty slots report unknown (None) so RMM skips them cleanly.""" + state = _state(Target(x=-782, y=1100, speed=-236)) + assert _target_sensor("x", 1, state).native_value is None + assert _target_sensor("y", 2, state).native_value is None + + +def test_presence_target_count() -> None: + """The count reflects how many slots hold a real detection (0..3).""" + count = LD2450BLEPresenceCountSensor.__new__(LD2450BLEPresenceCountSensor) + + count.coordinator = _fake_coordinator( + _state(Target(x=1, y=2, speed=3), Target(x=4, y=5, speed=6)) + ) + assert count.native_value == 2 + + count.coordinator = _fake_coordinator(_state()) + assert count.native_value == 0 + + count.coordinator = _fake_coordinator( + _state( + Target(x=1, y=1, speed=1), + Target(x=2, y=2, speed=2), + Target(x=3, y=3, speed=3), + ) + ) + assert count.native_value == 3 From 1c95debd4504c3cd859fb702c3ac1c45517d3efa Mon Sep 17 00:00:00 2001 From: magicx78 <66534521+magicx78@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:02:24 +0200 Subject: [PATCH 2/2] feat(rmm): make Radar Map Manager support an opt-in option Add an options flow toggle "Radar Map Manager support" (default OFF) so non-RMM users keep a clean entity list. - When OFF (default): per-target X/Y sensors are disabled by default and no presence_target_count sensor is created (original behaviour). - When ON: X/Y sensors are enabled and presence_target_count is added. - Toggling reloads the config entry (options update listener). - const: CONF_ENABLE_RMM / DEFAULT_ENABLE_RMM (False). - config_flow: OptionsFlow with the boolean; strings + en/de translations. - README: documents the opt-in step. - tests: option gating of X/Y enable-default + count, and an options-flow round-trip test. Full suite: 42 passed; ruff clean. Co-Authored-By: Claude Opus 4.8 --- README.md | 24 ++++-- custom_components/ld2450_ble/__init__.py | 8 ++ custom_components/ld2450_ble/config_flow.py | 35 ++++++++- custom_components/ld2450_ble/const.py | 5 ++ custom_components/ld2450_ble/sensor.py | 20 +++-- custom_components/ld2450_ble/strings.json | 13 ++++ .../ld2450_ble/translations/de.json | 13 ++++ .../ld2450_ble/translations/en.json | 13 ++++ tests/test_config_flow.py | 22 ++++++ tests/test_sensor.py | 74 +++++++++++-------- 10 files changed, 184 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f66114f..2ac04fe 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Created per device (target `N` = 1–3): | `binary_sensor` | Target N moving | Movement per target | | `sensor` | Target N distance | mm, enabled by default | | `sensor` | Target N angle | °, enabled by default | -| `sensor` | Target N X / Y | mm, enabled by default — unknown when the slot is empty | -| `sensor` | Presence target count | 0–3 currently present targets, enabled by default | +| `sensor` | Target N X / Y | mm — enabled when *Radar Map Manager support* is on; unknown when the slot is empty | +| `sensor` | Presence target count | 0–3 currently present targets; only created when *Radar Map Manager support* is on | | `sensor` | Target N speed | mm/s, disabled by default | | `sensor` | Target N resolution | mm, disabled by default | | `switch` | Multi-target tracking | On = multi, off = single | @@ -92,11 +92,21 @@ sensor._target_3_x sensor._target_3_y ``` -The `Target N X / Y` sensors above already provide exactly these — in -**millimetres**, which RMM uses as-is (it reads `unit_of_measurement`; `m` and -`cm` are scaled, `mm` is taken directly). They are **enabled by default**, and an -empty target slot reports `unknown`, which RMM skips cleanly. No extra -configuration, helper templates or duplicate sensors are required. +The `Target N X / Y` sensors provide exactly these — in **millimetres**, which RMM +uses as-is (it reads `unit_of_measurement`; `m` and `cm` are scaled, `mm` is taken +directly). An empty target slot reports `unknown`, which RMM skips cleanly. No +helper templates or duplicate sensors are required. + +### Turn it on (opt-in) + +RMM support is **off by default** so non-RMM users keep a clean entity list. To +enable it, go to **Settings → Devices & Services → LD2450 BLE → Configure** and +turn on **Radar Map Manager support**. The integration reloads and: + +- enables the six `Target N X / Y` sensors, and +- adds a `presence_target_count` sensor (0–3). + +Turning the option back off disables them again. ### Matching the radar name (and a language caveat) diff --git a/custom_components/ld2450_ble/__init__.py b/custom_components/ld2450_ble/__init__.py index 164e83d..3d7ee34 100644 --- a/custom_components/ld2450_ble/__init__.py +++ b/custom_components/ld2450_ble/__init__.py @@ -88,9 +88,17 @@ async def _async_stop(event: Event | None = None) -> None: entry.runtime_data = LD2450BLEData(entry.title, device, coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) return True +async def _async_options_updated( + hass: HomeAssistant, entry: LD2450BLEConfigEntry +) -> None: + """Reload the entry when its options change (e.g. RMM support toggled).""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: LD2450BLEConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/custom_components/ld2450_ble/config_flow.py b/custom_components/ld2450_ble/config_flow.py index 9c6f5b7..cf250f8 100644 --- a/custom_components/ld2450_ble/config_flow.py +++ b/custom_components/ld2450_ble/config_flow.py @@ -12,10 +12,16 @@ BluetoothServiceInfoBleak, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback -from .const import DOMAIN, LOCAL_NAMES +from .const import CONF_ENABLE_RMM, DEFAULT_ENABLE_RMM, DOMAIN, LOCAL_NAMES from .ld2450_ble import BLEAK_EXCEPTIONS, LD2450BLE _LOGGER = logging.getLogger(__name__) @@ -31,6 +37,12 @@ def __init__(self) -> None: self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Return the options flow handler.""" + return LD2450BleOptionsFlow() + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -104,3 +116,22 @@ async def async_step_user( ), errors=errors, ) + + +class LD2450BleOptionsFlow(OptionsFlow): + """Handle options for the LD2450 BLE integration.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the integration options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current = self.config_entry.options.get(CONF_ENABLE_RMM, DEFAULT_ENABLE_RMM) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + {vol.Required(CONF_ENABLE_RMM, default=current): bool} + ), + ) diff --git a/custom_components/ld2450_ble/const.py b/custom_components/ld2450_ble/const.py index 26390d1..db692ed 100644 --- a/custom_components/ld2450_ble/const.py +++ b/custom_components/ld2450_ble/const.py @@ -9,6 +9,11 @@ # Advertised local-name prefixes used for discovery filtering. LOCAL_NAMES: Final = {"HLK-LD2450"} +# Options-flow flag: expose Radar Map Manager-compatible entities (the per-target +# X/Y coordinate sensors enabled, plus a presence_target_count sensor). Opt-in. +CONF_ENABLE_RMM: Final = "enable_radar_map_manager" +DEFAULT_ENABLE_RMM: Final = False + # Debounce window for coalescing rapid push updates (seconds). UPDATE_DEBOUNCE: Final = 1.0 diff --git a/custom_components/ld2450_ble/sensor.py b/custom_components/ld2450_ble/sensor.py index 1b5e9f7..0a7b9e0 100644 --- a/custom_components/ld2450_ble/sensor.py +++ b/custom_components/ld2450_ble/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONNECTION_STATES +from .const import CONF_ENABLE_RMM, CONNECTION_STATES, DEFAULT_ENABLE_RMM from .coordinator import LD2450BLECoordinator from .entity import LD2450BLEDiagnosticEntity, LD2450BLEEntity from .ld2450_ble.models import Target @@ -54,8 +54,9 @@ class LD2450BLESensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, value_fn=lambda t: round(t.angle, 1), ), - # X/Y are enabled by default: they are the coordinates Radar Map Manager - # reads (sensor._target_N_x / _y, in mm). Empty slots report unknown. + # X/Y are the coordinates Radar Map Manager reads (sensor._target_N_x + # / _y, in mm). They are enabled by default only when the RMM option is on + # (see async_setup_entry); empty slots report unknown either way. LD2450BLESensorEntityDescription( key="x", translation_key="x", @@ -161,8 +162,10 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = entry.runtime_data.coordinator + rmm_enabled = entry.options.get(CONF_ENABLE_RMM, DEFAULT_ENABLE_RMM) + entities: list[SensorEntity] = [ - LD2450BLESensor(coordinator, description, index) + LD2450BLESensor(coordinator, description, index, rmm_enabled) for index in range(TARGET_COUNT) for description in SENSOR_TYPES ] @@ -170,7 +173,9 @@ async def async_setup_entry( LD2450BLEDiagnosticSensor(coordinator, description) for description in DIAGNOSTIC_SENSOR_TYPES ) - entities.append(LD2450BLEPresenceCountSensor(coordinator)) + # The presence-count sensor only exists when RMM support is enabled. + if rmm_enabled: + entities.append(LD2450BLEPresenceCountSensor(coordinator)) async_add_entities(entities) @@ -184,12 +189,17 @@ def __init__( coordinator: LD2450BLECoordinator, description: LD2450BLESensorEntityDescription, index: int, + rmm_enabled: bool = False, ) -> None: """Initialise the sensor.""" super().__init__(coordinator, f"target{index + 1}_{description.key}") self.entity_description = description self._index = index self._attr_translation_placeholders = {"target": str(index + 1)} + # X/Y are the RMM coordinate sensors: enable by default only when the + # Radar Map Manager option is on. + if description.none_when_absent: + self._attr_entity_registry_enabled_default = rmm_enabled @property def native_value(self) -> float | int | None: diff --git a/custom_components/ld2450_ble/strings.json b/custom_components/ld2450_ble/strings.json index ab259a9..7b18b14 100644 --- a/custom_components/ld2450_ble/strings.json +++ b/custom_components/ld2450_ble/strings.json @@ -18,6 +18,19 @@ "unknown": "Unexpected error." } }, + "options": { + "step": { + "init": { + "title": "LD2450 BLE options", + "data": { + "enable_radar_map_manager": "Radar Map Manager support" + }, + "data_description": { + "enable_radar_map_manager": "Enable the per-target X/Y coordinate sensors and a presence_target_count sensor for use with the Radar Map Manager integration." + } + } + } + }, "exceptions": { "device_not_found": { "message": "Could not find LD2450 device with address {address}. Make sure it is powered and in range of a Bluetooth proxy." diff --git a/custom_components/ld2450_ble/translations/de.json b/custom_components/ld2450_ble/translations/de.json index e72bc0c..09c00d4 100644 --- a/custom_components/ld2450_ble/translations/de.json +++ b/custom_components/ld2450_ble/translations/de.json @@ -18,6 +18,19 @@ "unknown": "Unerwarteter Fehler." } }, + "options": { + "step": { + "init": { + "title": "LD2450-BLE-Optionen", + "data": { + "enable_radar_map_manager": "Radar-Map-Manager-Unterstützung" + }, + "data_description": { + "enable_radar_map_manager": "Aktiviert die X/Y-Koordinatensensoren pro Ziel sowie einen presence_target_count-Sensor zur Nutzung mit der Radar-Map-Manager-Integration." + } + } + } + }, "exceptions": { "device_not_found": { "message": "LD2450-Gerät mit Adresse {address} nicht gefunden. Stelle sicher, dass es eingeschaltet und in Reichweite eines Bluetooth-Proxys ist." diff --git a/custom_components/ld2450_ble/translations/en.json b/custom_components/ld2450_ble/translations/en.json index ab259a9..7b18b14 100644 --- a/custom_components/ld2450_ble/translations/en.json +++ b/custom_components/ld2450_ble/translations/en.json @@ -18,6 +18,19 @@ "unknown": "Unexpected error." } }, + "options": { + "step": { + "init": { + "title": "LD2450 BLE options", + "data": { + "enable_radar_map_manager": "Radar Map Manager support" + }, + "data_description": { + "enable_radar_map_manager": "Enable the per-target X/Y coordinate sensors and a presence_target_count sensor for use with the Radar Map Manager integration." + } + } + } + }, "exceptions": { "device_not_found": { "message": "Could not find LD2450 device with address {address}. Make sure it is powered and in range of a Bluetooth proxy." diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index f3c2574..78082ed 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -91,3 +91,25 @@ async def test_bluetooth_discovery_already_configured( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_options_flow_toggles_rmm(hass: HomeAssistant) -> None: + """The options flow stores the Radar Map Manager support toggle.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.ld2450_ble.const import CONF_ENABLE_RMM + + entry = MockConfigEntry( + domain=DOMAIN, unique_id=ADDRESS, data={CONF_ADDRESS: ADDRESS} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], {CONF_ENABLE_RMM: True} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {CONF_ENABLE_RMM: True} diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ddbca4e..223eda9 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,8 +1,7 @@ """Unit tests for the RMM-compatible sensor behaviour. -These exercise the pure ``native_value`` logic of the X/Y coordinate sensors -and the presence-count sensor without spinning up Home Assistant, mirroring the -lightweight style of ``test_models.py``. +These exercise the X/Y coordinate sensors and the presence-count sensor without +spinning up Home Assistant, mirroring the lightweight style of ``test_models.py``. """ from __future__ import annotations @@ -18,6 +17,8 @@ LD2450BLESensor, ) +ADDRESS = "06:DE:83:53:1B:6F" + def _desc(key: str): """Return the sensor description with the given key.""" @@ -30,26 +31,25 @@ def _state(*targets: Target) -> LD2450BLEState: return LD2450BLEState(targets=tuple(slots)) -def _fake_coordinator(state: LD2450BLEState): - """A minimal stand-in exposing ``coordinator.device.state``.""" - return types.SimpleNamespace(device=types.SimpleNamespace(state=state)) +def _coordinator(state: LD2450BLEState): + """A minimal stand-in good enough for the entity constructors.""" + return types.SimpleNamespace( + device=types.SimpleNamespace(address=ADDRESS, state=state), + config_entry=types.SimpleNamespace(title="HLK-LD2450"), + ) -def _target_sensor(key: str, index: int, state: LD2450BLEState) -> LD2450BLESensor: - """Build an LD2450BLESensor without the HA entity machinery.""" - sensor = LD2450BLESensor.__new__(LD2450BLESensor) - sensor.entity_description = _desc(key) - sensor._index = index - sensor.coordinator = _fake_coordinator(state) - return sensor +def _target_sensor( + key: str, index: int, state: LD2450BLEState, rmm_enabled: bool = False +) -> LD2450BLESensor: + return LD2450BLESensor(_coordinator(state), _desc(key), index, rmm_enabled) -def test_xy_enabled_by_default() -> None: - """X/Y must be enabled by default and flagged none-when-absent for RMM.""" - for key in ("x", "y"): - desc = _desc(key) - assert desc.entity_registry_enabled_default is not False - assert desc.none_when_absent is True +def test_xy_none_when_absent_flag() -> None: + """X/Y are flagged none-when-absent; the other fields are not.""" + assert _desc("x").none_when_absent is True + assert _desc("y").none_when_absent is True + assert _desc("distance").none_when_absent is False def test_xy_unit_is_millimetres() -> None: @@ -58,6 +58,22 @@ def test_xy_unit_is_millimetres() -> None: assert _desc(key).native_unit_of_measurement == UnitOfLength.MILLIMETERS +def test_xy_enabled_follows_rmm_option() -> None: + """X/Y are enabled by default only when the RMM option is on.""" + st = _state() + + def enabled(key: str, rmm: bool) -> bool: + sensor = _target_sensor(key, 0, st, rmm_enabled=rmm) + return sensor.entity_registry_enabled_default + + assert enabled("x", True) + assert enabled("y", True) + assert not enabled("x", False) + assert not enabled("y", False) + # A non-coordinate sensor ignores the RMM flag (distance stays enabled). + assert enabled("distance", False) + + def test_xy_present_returns_coordinate() -> None: """A present target reports its raw mm coordinates.""" state = _state(Target(x=-782, y=1100, speed=-236)) @@ -74,21 +90,21 @@ def test_xy_absent_returns_none() -> None: def test_presence_target_count() -> None: """The count reflects how many slots hold a real detection (0..3).""" - count = LD2450BLEPresenceCountSensor.__new__(LD2450BLEPresenceCountSensor) - - count.coordinator = _fake_coordinator( - _state(Target(x=1, y=2, speed=3), Target(x=4, y=5, speed=6)) + count = LD2450BLEPresenceCountSensor( + _coordinator(_state(Target(x=1, y=2, speed=3), Target(x=4, y=5, speed=6))) ) assert count.native_value == 2 - count.coordinator = _fake_coordinator(_state()) + count = LD2450BLEPresenceCountSensor(_coordinator(_state())) assert count.native_value == 0 - count.coordinator = _fake_coordinator( - _state( - Target(x=1, y=1, speed=1), - Target(x=2, y=2, speed=2), - Target(x=3, y=3, speed=3), + count = LD2450BLEPresenceCountSensor( + _coordinator( + _state( + Target(x=1, y=1, speed=1), + Target(x=2, y=2, speed=2), + Target(x=3, y=3, speed=3), + ) ) ) assert count.native_value == 3