Skip to content

Commit a8a84dc

Browse files
tomer-wCopilot
andcommitted
Fix entity ID validation for hyphenated device names (#8)
- Sanitize hyphens to underscores in NMEA2000Sensor unique_id generation - Remove redundant hub.id property; use hub.name directly - Fix callers to use renamed sensor_id constructor parameter - Add pytest job to CI workflow - Add copilot-instructions.md for development guidelines - Add tests for hyphen sanitization - Bump version to 2026.3.3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6178594 commit a8a84dc

File tree

7 files changed

+128
-19
lines changed

7 files changed

+128
-19
lines changed

.github/copilot-instructions.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copilot Instructions for ha-nmea2000
2+
3+
## Project Overview
4+
5+
This is a Home Assistant custom integration for NMEA 2000 marine data. It connects to NMEA 2000 gateways (CAN, USB, TCP/EBYTE) and exposes PGN data as HA sensors.
6+
7+
## Project Structure
8+
9+
- `custom_components/nmea2000/` — Integration source code
10+
- `__init__.py` — Integration setup and entry points
11+
- `hub.py` — Gateway orchestration, sensor creation, PGN processing
12+
- `NMEA2000Sensor.py` — Sensor entity class
13+
- `config_flow.py` — Configuration UI flow
14+
- `const.py` — Constants and configuration keys
15+
- `tests/` — Pytest test suite
16+
- `.devcontainer/` — Dev container configuration for Linux-based development
17+
18+
## Key Dependencies
19+
20+
- `nmea2000` Python library (listed in `requirements.txt`)
21+
- `pytest-homeassistant-custom-component` for testing (listed in `requirements_test.txt`)
22+
23+
## Testing
24+
25+
### Important: Tests require Linux
26+
27+
Tests depend on `pytest-homeassistant-custom-component` which requires Linux. They **cannot** run natively on Windows.
28+
29+
### Running tests
30+
31+
Always run tests in Docker using the devcontainer image:
32+
33+
```bash
34+
cd C:\ttt\ha-nmea2000
35+
docker run --rm -v "${PWD}:/workspace" -w /workspace mcr.microsoft.com/devcontainers/python:3.13 bash -c "pip install --quiet -r requirements.txt -r requirements_test.txt 2>&1 | tail -3 && pytest tests/ -v 2>&1"
36+
```
37+
38+
### When to run tests
39+
40+
- **Always** run tests after making any code changes, before considering the task complete.
41+
- Run tests before committing.
42+
43+
## CI/CD
44+
45+
- `.github/workflows/validate.yaml` — Runs HACS validation, hassfest, and pytest on push/PR.
46+
- Tests in CI install both `requirements.txt` and `requirements_test.txt`.
47+
48+
## Code Conventions
49+
50+
- Entity ID sanitization (spaces, hyphens → underscores) happens in `NMEA2000Sensor.__init__`, not in the hub.
51+
- `hub.py` passes raw names as `sensor_id` to `NMEA2000Sensor`; the sensor class handles all ID normalization.
52+
- The `NMEA2000Sensor` constructor parameter is named `sensor_id` (not `id`).
53+
- Use `pyproject.toml` for all project metadata (PEP 621). No `setup.py`.

.github/workflows/validate.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,14 @@ jobs:
2020
steps:
2121
- uses: "actions/checkout@v6"
2222
- uses: "home-assistant/actions/hassfest@master"
23+
tests:
24+
runs-on: "ubuntu-latest"
25+
steps:
26+
- uses: "actions/checkout@v6"
27+
- uses: "actions/setup-python@v5"
28+
with:
29+
python-version: "3.13"
30+
- name: Install dependencies
31+
run: pip install -r requirements_test.txt
32+
- name: Run tests
33+
run: pytest tests/ -v

custom_components/nmea2000/NMEA2000Sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def __init__(
3131
need_state_class = isinstance(initial_state, (int, float))
3232
_LOGGER.info("Initializing NMEA2000Sensor: sensor_id=%s, friendly_name=%s, initial_state: %s (%s), unit_of_measurement=%s, device_name=%s, via_device=%s, update_frequncy=%s, ttl=%s, need_state_class=%s",
3333
sensor_id, friendly_name, initial_state, type(initial_state), unit_of_measurement, device_name, via_device, update_frequncy, ttl, need_state_class)
34-
self._attr_unique_id = sensor_id.lower().replace(" ", "_")
34+
self._attr_unique_id = sensor_id.lower().replace(" ", "_").replace("-", "_")
3535
self._attr_name = friendly_name
3636
self._device_name = device_name
3737
self._attr_native_value = initial_state

custom_components/nmea2000/hub.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
9494

9595
# Retrieve configuration from entry
9696
self.name = entry.data[CONF_NAME]
97-
self.id = self.name.lower().replace(" ", "_")
9897
self.time_between_updates = timedelta(milliseconds=entry.data.get(CONF_MS_BETWEEN_UPDATES, 5000))
9998
mode = entry.data[CONF_MODE]
10099
self.device_name = f"NMEA 2000 {mode} Gateway"
@@ -152,21 +151,21 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
152151

153152
# Create system sensors
154153
self.state_sensor = NMEA2000Sensor(
155-
id=self.id+"_state",
154+
sensor_id=self.name+"_state",
156155
friendly_name="State",
157156
initial_state=self.state,
158157
device_name=self.device_name,
159158
)
160159
self.total_messages_sensor = NMEA2000Sensor(
161-
id=self.id+"_total_messages",
160+
sensor_id=self.name+"_total_messages",
162161
friendly_name="Total message count",
163162
initial_state=0,
164163
unit_of_measurement="messages",
165164
device_name=self.device_name,
166165
update_frequncy=self.time_between_updates,
167166
)
168167
self.msg_per_minute_sensor = NMEA2000Sensor(
169-
id=self.id+"_messages_per_minute",
168+
sensor_id=self.name+"_messages_per_minute",
170169
friendly_name="Messages per minute",
171170
initial_state=0,
172171
unit_of_measurement="msg/min",
@@ -382,7 +381,7 @@ async def receive_callback(self, message: NMEA2000Message) -> None:
382381
if pgn_sensor is None:
383382
_LOGGER.info("Creating new sensor for PGN %d", message.PGN)
384383
sensor = NMEA2000Sensor(
385-
id=self.id + "_" + message.id,
384+
sensor_id=self.name + "_" + message.id,
386385
friendly_name=f"PGN {message.PGN} message count",
387386
initial_state=1,
388387
unit_of_measurement="count",
@@ -401,7 +400,7 @@ async def receive_callback(self, message: NMEA2000Message) -> None:
401400
)
402401
pgn_sensor.set_state(new_value, ignore_tracing = True)
403402

404-
sensor_name_prefix = f"{self.id}_{message.PGN}_{message.id}_{message.hash}_"
403+
sensor_name_prefix = f"{self.name}_{message.PGN}_{message.id}_{message.hash}_"
405404

406405
# Process individual fields in the message
407406
for field in message.fields:
@@ -436,7 +435,7 @@ async def receive_callback(self, message: NMEA2000Message) -> None:
436435
if sensor is None:
437436
# Create new sensor
438437
sensor = NMEA2000Sensor(
439-
id=sensor_id,
438+
sensor_id=sensor_id,
440439
friendly_name=field.name,
441440
initial_state=field.value,
442441
unit_of_measurement=field.unit_of_measurement,

custom_components/nmea2000/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@
1313
"requirements": [
1414
"nmea2000==2026.3.3"
1515
],
16-
"version": "2026.3.2"
16+
"version": "2026.3.3"
1717
}

tests/test_hub.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,24 @@ async def test_hub_status_callback_disconnected(mock_can_cls, hass):
172172
await hub.status_callback(State.DISCONNECTED)
173173
assert hub.state == "Disconnected"
174174

175+
176+
@patch("custom_components.nmea2000.hub.PythonCanAsyncIOClient")
177+
async def test_hub_hyphenated_name_creates_valid_sensors(mock_can_cls, hass):
178+
"""Test Hub with hyphenated name creates sensors with sanitized unique IDs."""
179+
mock_can_cls.return_value = MagicMock()
180+
mock_can_cls.return_value.set_receive_callback = MagicMock()
181+
mock_can_cls.return_value.set_status_callback = MagicMock()
182+
183+
entry = _make_entry(hass, CONF_MODE_CAN, {
184+
"name": "YDEN-02",
185+
CONF_CAN_INTERFACE: "socketcan",
186+
CONF_CAN_CHANNEL: "can0",
187+
CONF_CAN_BITRATE: 250000,
188+
})
189+
hub = Hub(hass, entry)
190+
191+
assert hub.name == "YDEN-02"
192+
assert "-" not in hub.state_sensor._attr_unique_id
193+
assert "-" not in hub.total_messages_sensor._attr_unique_id
194+
assert "-" not in hub.msg_per_minute_sensor._attr_unique_id
195+

tests/test_sensor.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
async def test_sensor_init_numeric(hass):
99
"""Test sensor initialization with numeric state."""
1010
sensor = NMEA2000Sensor(
11-
id="test_sensor",
11+
sensor_id="test_sensor",
1212
friendly_name="Temperature",
1313
initial_state=25.5,
1414
unit_of_measurement="°C",
@@ -23,7 +23,7 @@ async def test_sensor_init_numeric(hass):
2323
async def test_sensor_init_string(hass):
2424
"""Test sensor initialization with string state."""
2525
sensor = NMEA2000Sensor(
26-
id="test_state",
26+
sensor_id="test_state",
2727
friendly_name="Status",
2828
initial_state="Running",
2929
device_name="Test Device",
@@ -35,7 +35,7 @@ async def test_sensor_init_string(hass):
3535
async def test_sensor_init_none_unavailable(hass):
3636
"""Test sensor with None initial state is unavailable."""
3737
sensor = NMEA2000Sensor(
38-
id="test_null",
38+
sensor_id="test_null",
3939
friendly_name="Empty",
4040
initial_state=None,
4141
device_name="Test Device",
@@ -46,7 +46,7 @@ async def test_sensor_init_none_unavailable(hass):
4646
async def test_sensor_str_repr(hass):
4747
"""Test sensor string representation."""
4848
sensor = NMEA2000Sensor(
49-
id="test_repr",
49+
sensor_id="test_repr",
5050
friendly_name="Wind Speed",
5151
initial_state=12.3,
5252
unit_of_measurement="kts",
@@ -60,7 +60,7 @@ async def test_sensor_str_repr(hass):
6060
async def test_sensor_set_state_not_ready(hass):
6161
"""Test set_state does nothing when sensor is not ready."""
6262
sensor = NMEA2000Sensor(
63-
id="test_not_ready",
63+
sensor_id="test_not_ready",
6464
friendly_name="Test",
6565
initial_state=0,
6666
device_name="Device",
@@ -73,7 +73,7 @@ async def test_sensor_set_state_not_ready(hass):
7373
async def test_sensor_set_state_ready(hass):
7474
"""Test set_state updates value when sensor is ready."""
7575
sensor = NMEA2000Sensor(
76-
id="test_ready",
76+
sensor_id="test_ready",
7777
friendly_name="Test",
7878
initial_state=0,
7979
device_name="Device",
@@ -87,7 +87,7 @@ async def test_sensor_set_state_ready(hass):
8787
async def test_sensor_update_availability_not_ready(hass):
8888
"""Test update_availability does nothing when not ready."""
8989
sensor = NMEA2000Sensor(
90-
id="test_avail",
90+
sensor_id="test_avail",
9191
friendly_name="Test",
9292
initial_state=0,
9393
device_name="Device",
@@ -99,7 +99,7 @@ async def test_sensor_update_availability_not_ready(hass):
9999
async def test_sensor_via_device(hass):
100100
"""Test sensor with via_device creates proper device info."""
101101
sensor = NMEA2000Sensor(
102-
id="test_via",
102+
sensor_id="test_via",
103103
friendly_name="Test",
104104
initial_state=0,
105105
device_name="SubDevice",
@@ -112,7 +112,7 @@ async def test_sensor_custom_update_frequency(hass):
112112
"""Test sensor with custom update frequency."""
113113
freq = timedelta(seconds=10)
114114
sensor = NMEA2000Sensor(
115-
id="test_freq",
115+
sensor_id="test_freq",
116116
friendly_name="Test",
117117
initial_state=0,
118118
device_name="Device",
@@ -125,11 +125,36 @@ async def test_sensor_custom_ttl(hass):
125125
"""Test sensor with custom TTL."""
126126
ttl = timedelta(seconds=30)
127127
sensor = NMEA2000Sensor(
128-
id="test_ttl",
128+
sensor_id="test_ttl",
129129
friendly_name="Test",
130130
initial_state=0,
131131
device_name="Device",
132132
ttl=ttl,
133133
)
134134
# TTL is multiplied by UNAVAILABLE_FACTOR (10)
135135
assert sensor.ttl == ttl * 10
136+
137+
138+
async def test_sensor_hyphenated_id_sanitized(hass):
139+
"""Test that hyphens in sensor_id are replaced with underscores in unique_id."""
140+
sensor = NMEA2000Sensor(
141+
sensor_id="yden-02_126993_heartbeat",
142+
friendly_name="Heartbeat",
143+
initial_state=0,
144+
device_name="Test Device",
145+
)
146+
assert "-" not in sensor._attr_unique_id
147+
assert sensor._attr_unique_id == "yden_02_126993_heartbeat"
148+
149+
150+
async def test_sensor_multiple_special_chars_sanitized(hass):
151+
"""Test that both spaces and hyphens are sanitized in unique_id."""
152+
sensor = NMEA2000Sensor(
153+
sensor_id="MY-DEVICE name-test",
154+
friendly_name="Test",
155+
initial_state=0,
156+
device_name="Test Device",
157+
)
158+
assert "-" not in sensor._attr_unique_id
159+
assert " " not in sensor._attr_unique_id
160+
assert sensor._attr_unique_id == "my_device_name_test"

0 commit comments

Comments
 (0)