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
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 |
Expand Down Expand Up @@ -74,6 +75,83 @@ 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.<radar>_target_1_x
sensor.<radar>_target_1_y
sensor.<radar>_target_2_x
sensor.<radar>_target_2_y
sensor.<radar>_target_3_x
sensor.<radar>_target_3_y
```

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)

RMM looks up the literal entity-ids `sensor.<radar>_target_<n>_x` / `_y`, where
`<radar>` 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_<n>_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_<n>_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_<n>` 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_<map>_master`), so it does not consume this sensor directly.

## Installation

### HACS (custom repository)
Expand Down
8 changes: 8 additions & 0 deletions custom_components/ld2450_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
35 changes: 33 additions & 2 deletions custom_components/ld2450_ble/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand Down Expand Up @@ -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}
),
)
5 changes: 5 additions & 0 deletions custom_components/ld2450_ble/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 47 additions & 5 deletions custom_components/ld2450_ble/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ...] = (
Expand All @@ -50,13 +54,16 @@ class LD2450BLESensorEntityDescription(SensorEntityDescription):
suggested_display_precision=1,
value_fn=lambda t: round(t.angle, 1),
),
# X/Y are the coordinates Radar Map Manager reads (sensor.<device>_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",
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(
Expand All @@ -65,7 +72,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(
Expand Down Expand Up @@ -155,15 +162,20 @@ 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
]
entities.extend(
LD2450BLEDiagnosticSensor(coordinator, description)
for description in DIAGNOSTIC_SENSOR_TYPES
)
# The presence-count sensor only exists when RMM support is enabled.
if rmm_enabled:
entities.append(LD2450BLEPresenceCountSensor(coordinator))
async_add_entities(entities)


Expand All @@ -177,17 +189,24 @@ 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:
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)


Expand All @@ -209,3 +228,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.<device>_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
)
14 changes: 14 additions & 0 deletions custom_components/ld2450_ble/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -34,6 +47,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" },
Expand Down
14 changes: 14 additions & 0 deletions custom_components/ld2450_ble/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -34,6 +47,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" },
Expand Down
14 changes: 14 additions & 0 deletions custom_components/ld2450_ble/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -34,6 +47,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" },
Expand Down
Loading
Loading