Skip to content

Commit cde90da

Browse files
Entity support (#1616)
* Config flow for entity association * Translations * Library lookup for entities with device * Update lint * Update lint * config flow unique id * Config flow * Fix config flow * Add entity to store * Work on service, coordinator and device * Start of entity sensors * Fix config flow translation * Work on battery replaced for entity * WIP * Remove entity filter * Entity naming * Translations * Fix battery replaced service for entity_id * Services & Events * Events * Events * Update docs * Entity naming * Entity naming * Refactoring * Fix config for template * Fix config flow template * Config flow * Revert ruff check * Lint fixes * Fix sensor updates
1 parent 60816e4 commit cde90da

36 files changed

+1159
-305
lines changed

.github/workflows/lint.yml

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,6 @@ jobs:
5959
run: |
6060
if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi
6161
62-
- name: Lint the code with ruff
62+
- name: Analyse the code with ruff
6363
run: |
64-
ruff check $(git ls-files '*.py') --output-format sarif -o results.sarif
65-
66-
- name: Upload SARIF file
67-
uses: github/codeql-action/upload-sarif@v3
68-
with:
69-
sarif_file: results.sarif
70-
category: ruff
64+
python3 -m ruff check .

.ruff.toml

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,45 @@ target-version = "py310"
44

55
[lint]
66
select = [
7-
"B007", # Loop control variable {name} not used within loop body
8-
"B014", # Exception handler with duplicate exception
9-
"C", # complexity
10-
"D", # docstrings
11-
"E", # pycodestyle
12-
"F", # pyflakes/autoflake
13-
"ICN001", # import concentions; {name} should be imported as {asname}
7+
"B007", # Loop control variable {name} not used within loop body
8+
"B014", # Exception handler with duplicate exception
9+
"C", # complexity
10+
"D", # docstrings
11+
"E", # pycodestyle
12+
"F", # pyflakes/autoflake
13+
"ICN001", # import concentions; {name} should be imported as {asname}
1414
"PGH004", # Use specific rule codes when using noqa
1515
"PLC0414", # Useless import alias. Import alias does not rename original package.
16-
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
17-
"SIM117", # Merge with-statements that use the same scope
18-
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
19-
"SIM201", # Use {left} != {right} instead of not {left} == {right}
20-
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
21-
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
22-
"SIM401", # Use get from dict with default instead of an if block
23-
"T20", # flake8-print
24-
"TRY004", # Prefer TypeError exception for invalid type
25-
"RUF006", # Store a reference to the return value of asyncio.create_task
26-
"UP", # pyupgrade
27-
"W", # pycodestyle
16+
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
17+
"SIM117", # Merge with-statements that use the same scope
18+
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
19+
"SIM201", # Use {left} != {right} instead of not {left} == {right}
20+
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
21+
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
22+
"SIM401", # Use get from dict with default instead of an if block
23+
"T20", # flake8-print
24+
"TRY004", # Prefer TypeError exception for invalid type
25+
"RUF006", # Store a reference to the return value of asyncio.create_task
26+
"UP", # pyupgrade
27+
"W", # pycodestyle
2828
]
2929
ignore = [
30-
"D202", # No blank lines allowed after function docstring
31-
"D203", # 1 blank line required before class docstring
32-
"D213", # Multi-line docstring summary should start at the second line
33-
"D404", # First word of the docstring should not be This
34-
"D406", # Section name should end with a newline
35-
"D407", # Section name underlining
36-
"D411", # Missing blank line before section
37-
"E501", # line too long
38-
"E731", # do not assign a lambda expression, use a def
30+
"D202", # No blank lines allowed after function docstring
31+
"D203", # 1 blank line required before class docstring
32+
"D213", # Multi-line docstring summary should start at the second line
33+
"D404", # First word of the docstring should not be This
34+
"D406", # Section name should end with a newline
35+
"D407", # Section name underlining
36+
"D411", # Missing blank line before section
37+
"E501", # line too long
38+
"E731", # do not assign a lambda expression, use a def
3939
]
4040

41-
[flake8-pytest-style]
41+
[lint.flake8-pytest-style]
4242
fixture-parentheses = false
4343

44-
[pyupgrade]
44+
[lint.pyupgrade]
4545
keep-runtime-typing = true
4646

47-
[mccabe]
47+
[lint.mccabe]
4848
max-complexity = 25

custom_components/battery_notes/__init__.py

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
DATA_STORE,
6363
ATTR_REMOVE,
6464
ATTR_DEVICE_ID,
65+
ATTR_SOURCE_ENTITY_ID,
6566
ATTR_DEVICE_NAME,
6667
ATTR_BATTERY_TYPE_AND_QUANTITY,
6768
ATTR_BATTERY_TYPE,
@@ -279,6 +280,7 @@ def register_services(hass: HomeAssistant):
279280
async def handle_battery_replaced(call):
280281
"""Handle the service call."""
281282
device_id = call.data.get(ATTR_DEVICE_ID, "")
283+
source_entity_id = call.data.get(ATTR_SOURCE_ENTITY_ID, "")
282284
datetime_replaced_entry = call.data.get(SERVICE_DATA_DATE_TIME_REPLACED)
283285

284286
if datetime_replaced_entry:
@@ -288,45 +290,85 @@ async def handle_battery_replaced(call):
288290
else:
289291
datetime_replaced = datetime.utcnow()
290292

293+
entity_registry = er.async_get(hass)
291294
device_registry = dr.async_get(hass)
292295

293-
device_entry = device_registry.async_get(device_id)
294-
if not device_entry:
295-
_LOGGER.error(
296-
"Device %s not found",
297-
device_id,
298-
)
299-
return
300-
301-
for entry_id in device_entry.config_entries:
302-
if (
303-
entry := hass.config_entries.async_get_entry(entry_id)
304-
) and entry.domain == DOMAIN:
305-
coordinator = (
306-
hass.data[DOMAIN][DATA].devices[entry.entry_id].coordinator
296+
if source_entity_id:
297+
source_entity_entry = entity_registry.async_get(source_entity_id)
298+
if not source_entity_entry:
299+
_LOGGER.error(
300+
"Entity %s not found",
301+
source_entity_id,
307302
)
303+
return
308304

309-
device_entry = {"battery_last_replaced": datetime_replaced}
305+
# entity_id is the associated entity, now need to find the config entry for battery notes
306+
for config_entry in hass.config_entries.async_entries(DOMAIN):
307+
if config_entry.data.get("source_entity_id") == source_entity_id:
308+
config_entry_id = config_entry.entry_id
310309

311-
coordinator.async_update_device_config(
312-
device_id=device_id, data=device_entry
313-
)
310+
coordinator = (
311+
hass.data[DOMAIN][DATA].devices[config_entry_id].coordinator
312+
)
313+
314+
entry = {"battery_last_replaced": datetime_replaced}
315+
316+
coordinator.async_update_entity_config(
317+
entity_id=source_entity_id, data=entry
318+
)
319+
await coordinator.async_request_refresh()
320+
321+
_LOGGER.debug(
322+
"Entity %s battery replaced on %s",
323+
source_entity_id,
324+
str(datetime_replaced),
325+
)
314326

315-
await coordinator.async_request_refresh()
327+
return
316328

317-
_LOGGER.debug(
318-
"Device %s battery replaced on %s",
329+
_LOGGER.error(
330+
"Entity %s not configured in Battery Notes",
331+
source_entity_id
332+
)
333+
334+
else:
335+
device_entry = device_registry.async_get(device_id)
336+
if not device_entry:
337+
_LOGGER.error(
338+
"Device %s not found",
319339
device_id,
320-
str(datetime_replaced),
321340
)
322-
323-
# Found and dealt with, exit
324341
return
325342

326-
_LOGGER.error(
327-
"Device %s not configured in Battery Notes",
328-
device_id,
329-
)
343+
for entry_id in device_entry.config_entries:
344+
if (
345+
entry := hass.config_entries.async_get_entry(entry_id)
346+
) and entry.domain == DOMAIN:
347+
coordinator = (
348+
hass.data[DOMAIN][DATA].devices[entry.entry_id].coordinator
349+
)
350+
351+
device_entry = {"battery_last_replaced": datetime_replaced}
352+
353+
coordinator.async_update_device_config(
354+
device_id=device_id, data=device_entry
355+
)
356+
357+
await coordinator.async_request_refresh()
358+
359+
_LOGGER.debug(
360+
"Device %s battery replaced on %s",
361+
device_id,
362+
str(datetime_replaced),
363+
)
364+
365+
# Found and dealt with, exit
366+
return
367+
368+
_LOGGER.error(
369+
"Device %s not configured in Battery Notes",
370+
device_id,
371+
)
330372

331373
async def handle_battery_last_reported(call):
332374
"""Handle the service call."""
@@ -346,6 +388,7 @@ async def handle_battery_last_reported(call):
346388
EVENT_BATTERY_NOT_REPORTED,
347389
{
348390
ATTR_DEVICE_ID: device.coordinator.device_id,
391+
ATTR_SOURCE_ENTITY_ID: device.coordinator.source_entity_id,
349392
ATTR_DEVICE_NAME: device.coordinator.device_name,
350393
ATTR_BATTERY_TYPE_AND_QUANTITY: device.coordinator.battery_type_and_quantity,
351394
ATTR_BATTERY_TYPE: device.coordinator.battery_type,
@@ -374,6 +417,7 @@ async def handle_battery_low(call):
374417
{
375418
ATTR_DEVICE_ID: device.coordinator.device_id,
376419
ATTR_DEVICE_NAME: device.coordinator.device_name,
420+
ATTR_SOURCE_ENTITY_ID: device.coordinator.source_entity_id,
377421
ATTR_BATTERY_LOW: device.coordinator.battery_low,
378422
ATTR_BATTERY_TYPE_AND_QUANTITY: device.coordinator.battery_type_and_quantity,
379423
ATTR_BATTERY_TYPE: device.coordinator.battery_type,

custom_components/battery_notes/binary_sensor.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
import voluptuous as vol
1010

1111
from homeassistant.config_entries import ConfigEntry
12-
from homeassistant.const import CONF_ENTITY_ID
1312
from homeassistant.core import (
1413
HomeAssistant,
1514
callback,
1615
Event,
16+
split_entity_id,
1717
)
1818
from homeassistant.exceptions import TemplateError
1919
from homeassistant.helpers.entity import Entity
@@ -64,9 +64,10 @@
6464
DOMAIN,
6565
DATA,
6666
ATTR_BATTERY_LOW_THRESHOLD,
67+
CONF_SOURCE_ENTITY_ID,
6768
)
6869

69-
from .common import isfloat
70+
from .common import validate_is_float
7071

7172
from .device import BatteryNotesDevice
7273
from .coordinator import BatteryNotesCoordinator
@@ -87,22 +88,25 @@ class BatteryNotesBinarySensorEntityDescription(
8788

8889
unique_id_suffix: str
8990

90-
9191
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
92-
{vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string}
92+
{
93+
vol.Optional(CONF_NAME): cv.string,
94+
vol.Optional(CONF_DEVICE_ID): cv.string,
95+
vol.Optional(CONF_SOURCE_ENTITY_ID): cv.string,
96+
}
9397
)
9498

95-
9699
@callback
97100
def async_add_to_device(hass: HomeAssistant, entry: ConfigEntry) -> str | None:
98101
"""Add our config entry to the device."""
99102
device_registry = dr.async_get(hass)
100103

101104
device_id = entry.data.get(CONF_DEVICE_ID)
102105

103-
if device_registry.async_get(device_id):
104-
device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id)
105-
return device_id
106+
if device_id:
107+
if device_registry.async_get(device_id):
108+
device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id)
109+
return device_id
106110
return None
107111

108112
async def async_setup_entry(
@@ -133,7 +137,7 @@ async def async_registry_updated(event: Event) -> None:
133137
# If the tracked battery note is no longer in the device, remove our config entry
134138
# from the device
135139
if (
136-
not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID]))
140+
not (entity_entry := entity_registry.async_get(data["entity_id"]))
137141
or not device_registry.async_get(device_id)
138142
or entity_entry.device_id == device_id
139143
):
@@ -152,10 +156,13 @@ async def async_registry_updated(event: Event) -> None:
152156
)
153157
)
154158

155-
device_id = async_add_to_device(hass, config_entry)
159+
device: BatteryNotesDevice = hass.data[DOMAIN][DATA].devices[config_entry.entry_id]
156160

157-
if not device_id:
158-
return
161+
if not device.fake_device:
162+
device_id = async_add_to_device(hass, config_entry)
163+
164+
if not device_id:
165+
return
159166

160167
description = BatteryNotesBinarySensorEntityDescription(
161168
unique_id_suffix="_battery_low",
@@ -315,7 +322,6 @@ def __init__(
315322
self.coordinator = coordinator
316323
self.entity_description = description
317324
self._attr_unique_id = unique_id
318-
self._attr_has_entity_name = True
319325
self._template_attrs: dict[Template, list[_TemplateAttribute]] = {}
320326

321327
super().__init__(coordinator=coordinator)
@@ -328,7 +334,18 @@ def __init__(
328334
identifiers=device_entry.identifiers,
329335
)
330336

331-
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
337+
self._attr_has_entity_name = True
338+
339+
if coordinator.source_entity_id and not coordinator.device_id:
340+
self._attr_translation_placeholders = {"device_name": coordinator.device_name + " "}
341+
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
342+
elif coordinator.source_entity_id and coordinator.device_id:
343+
source_entity_domain, source_object_id = split_entity_id(coordinator.source_entity_id)
344+
self._attr_translation_placeholders = {"device_name": coordinator.source_entity_name + " "}
345+
self.entity_id = f"binary_sensor.{source_object_id}_{description.key}"
346+
else:
347+
self._attr_translation_placeholders = {"device_name": ""}
348+
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
332349

333350
self._template = template
334351
self._state: bool | None = None
@@ -497,9 +514,21 @@ def __init__(
497514
device_registry = dr.async_get(hass)
498515

499516
self.coordinator = coordinator
517+
self._attr_has_entity_name = True
518+
519+
if coordinator.source_entity_id and not coordinator.device_id:
520+
self._attr_translation_placeholders = {"device_name": coordinator.device_name + " "}
521+
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
522+
elif coordinator.source_entity_id and coordinator.device_id:
523+
source_entity_domain, source_object_id = split_entity_id(coordinator.source_entity_id)
524+
self._attr_translation_placeholders = {"device_name": coordinator.source_entity_name + " "}
525+
self.entity_id = f"binary_sensor.{source_object_id}_{description.key}"
526+
else:
527+
self._attr_translation_placeholders = {"device_name": ""}
528+
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
529+
500530
self.entity_description = description
501531
self._attr_unique_id = unique_id
502-
self._attr_has_entity_name = True
503532

504533
super().__init__(coordinator=coordinator)
505534

@@ -511,8 +540,6 @@ def __init__(
511540
identifiers=device_entry.identifiers,
512541
)
513542

514-
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
515-
516543
async def async_added_to_hass(self) -> None:
517544
"""Handle added to Hass."""
518545

@@ -536,7 +563,7 @@ def _handle_coordinator_update(self) -> None:
536563
STATE_UNAVAILABLE,
537564
STATE_UNKNOWN,
538565
]
539-
or not isfloat(wrapped_battery_state.state)
566+
or not validate_is_float(wrapped_battery_state.state)
540567
):
541568
self._attr_is_on = None
542569
self._attr_available = False

0 commit comments

Comments
 (0)