Skip to content

Commit

Permalink
Entity support (#1616)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
andrew-codechimp authored May 9, 2024
1 parent 60816e4 commit cde90da
Show file tree
Hide file tree
Showing 36 changed files with 1,159 additions and 305 deletions.
10 changes: 2 additions & 8 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,6 @@ jobs:
run: |
if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi
- name: Lint the code with ruff
- name: Analyse the code with ruff
run: |
ruff check $(git ls-files '*.py') --output-format sarif -o results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
category: ruff
python3 -m ruff check .
62 changes: 31 additions & 31 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,45 @@ target-version = "py310"

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

[flake8-pytest-style]
[lint.flake8-pytest-style]
fixture-parentheses = false

[pyupgrade]
[lint.pyupgrade]
keep-runtime-typing = true

[mccabe]
[lint.mccabe]
max-complexity = 25
100 changes: 72 additions & 28 deletions custom_components/battery_notes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
DATA_STORE,
ATTR_REMOVE,
ATTR_DEVICE_ID,
ATTR_SOURCE_ENTITY_ID,
ATTR_DEVICE_NAME,
ATTR_BATTERY_TYPE_AND_QUANTITY,
ATTR_BATTERY_TYPE,
Expand Down Expand Up @@ -279,6 +280,7 @@ def register_services(hass: HomeAssistant):
async def handle_battery_replaced(call):
"""Handle the service call."""
device_id = call.data.get(ATTR_DEVICE_ID, "")
source_entity_id = call.data.get(ATTR_SOURCE_ENTITY_ID, "")
datetime_replaced_entry = call.data.get(SERVICE_DATA_DATE_TIME_REPLACED)

if datetime_replaced_entry:
Expand All @@ -288,45 +290,85 @@ async def handle_battery_replaced(call):
else:
datetime_replaced = datetime.utcnow()

entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)

device_entry = device_registry.async_get(device_id)
if not device_entry:
_LOGGER.error(
"Device %s not found",
device_id,
)
return

for entry_id in device_entry.config_entries:
if (
entry := hass.config_entries.async_get_entry(entry_id)
) and entry.domain == DOMAIN:
coordinator = (
hass.data[DOMAIN][DATA].devices[entry.entry_id].coordinator
if source_entity_id:
source_entity_entry = entity_registry.async_get(source_entity_id)
if not source_entity_entry:
_LOGGER.error(
"Entity %s not found",
source_entity_id,
)
return

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

coordinator.async_update_device_config(
device_id=device_id, data=device_entry
)
coordinator = (
hass.data[DOMAIN][DATA].devices[config_entry_id].coordinator
)

entry = {"battery_last_replaced": datetime_replaced}

coordinator.async_update_entity_config(
entity_id=source_entity_id, data=entry
)
await coordinator.async_request_refresh()

_LOGGER.debug(
"Entity %s battery replaced on %s",
source_entity_id,
str(datetime_replaced),
)

await coordinator.async_request_refresh()
return

_LOGGER.debug(
"Device %s battery replaced on %s",
_LOGGER.error(
"Entity %s not configured in Battery Notes",
source_entity_id
)

else:
device_entry = device_registry.async_get(device_id)
if not device_entry:
_LOGGER.error(
"Device %s not found",
device_id,
str(datetime_replaced),
)

# Found and dealt with, exit
return

_LOGGER.error(
"Device %s not configured in Battery Notes",
device_id,
)
for entry_id in device_entry.config_entries:
if (
entry := hass.config_entries.async_get_entry(entry_id)
) and entry.domain == DOMAIN:
coordinator = (
hass.data[DOMAIN][DATA].devices[entry.entry_id].coordinator
)

device_entry = {"battery_last_replaced": datetime_replaced}

coordinator.async_update_device_config(
device_id=device_id, data=device_entry
)

await coordinator.async_request_refresh()

_LOGGER.debug(
"Device %s battery replaced on %s",
device_id,
str(datetime_replaced),
)

# Found and dealt with, exit
return

_LOGGER.error(
"Device %s not configured in Battery Notes",
device_id,
)

async def handle_battery_last_reported(call):
"""Handle the service call."""
Expand All @@ -346,6 +388,7 @@ async def handle_battery_last_reported(call):
EVENT_BATTERY_NOT_REPORTED,
{
ATTR_DEVICE_ID: device.coordinator.device_id,
ATTR_SOURCE_ENTITY_ID: device.coordinator.source_entity_id,
ATTR_DEVICE_NAME: device.coordinator.device_name,
ATTR_BATTERY_TYPE_AND_QUANTITY: device.coordinator.battery_type_and_quantity,
ATTR_BATTERY_TYPE: device.coordinator.battery_type,
Expand Down Expand Up @@ -374,6 +417,7 @@ async def handle_battery_low(call):
{
ATTR_DEVICE_ID: device.coordinator.device_id,
ATTR_DEVICE_NAME: device.coordinator.device_name,
ATTR_SOURCE_ENTITY_ID: device.coordinator.source_entity_id,
ATTR_BATTERY_LOW: device.coordinator.battery_low,
ATTR_BATTERY_TYPE_AND_QUANTITY: device.coordinator.battery_type_and_quantity,
ATTR_BATTERY_TYPE: device.coordinator.battery_type,
Expand Down
63 changes: 45 additions & 18 deletions custom_components/battery_notes/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import (
HomeAssistant,
callback,
Event,
split_entity_id,
)
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.entity import Entity
Expand Down Expand Up @@ -64,9 +64,10 @@
DOMAIN,
DATA,
ATTR_BATTERY_LOW_THRESHOLD,
CONF_SOURCE_ENTITY_ID,
)

from .common import isfloat
from .common import validate_is_float

from .device import BatteryNotesDevice
from .coordinator import BatteryNotesCoordinator
Expand All @@ -87,22 +88,25 @@ class BatteryNotesBinarySensorEntityDescription(

unique_id_suffix: str


PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string}
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_DEVICE_ID): cv.string,
vol.Optional(CONF_SOURCE_ENTITY_ID): cv.string,
}
)


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

device_id = entry.data.get(CONF_DEVICE_ID)

if device_registry.async_get(device_id):
device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id)
return device_id
if device_id:
if device_registry.async_get(device_id):
device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id)
return device_id
return None

async def async_setup_entry(
Expand Down Expand Up @@ -133,7 +137,7 @@ async def async_registry_updated(event: Event) -> None:
# If the tracked battery note is no longer in the device, remove our config entry
# from the device
if (
not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID]))
not (entity_entry := entity_registry.async_get(data["entity_id"]))
or not device_registry.async_get(device_id)
or entity_entry.device_id == device_id
):
Expand All @@ -152,10 +156,13 @@ async def async_registry_updated(event: Event) -> None:
)
)

device_id = async_add_to_device(hass, config_entry)
device: BatteryNotesDevice = hass.data[DOMAIN][DATA].devices[config_entry.entry_id]

if not device_id:
return
if not device.fake_device:
device_id = async_add_to_device(hass, config_entry)

if not device_id:
return

description = BatteryNotesBinarySensorEntityDescription(
unique_id_suffix="_battery_low",
Expand Down Expand Up @@ -315,7 +322,6 @@ def __init__(
self.coordinator = coordinator
self.entity_description = description
self._attr_unique_id = unique_id
self._attr_has_entity_name = True
self._template_attrs: dict[Template, list[_TemplateAttribute]] = {}

super().__init__(coordinator=coordinator)
Expand All @@ -328,7 +334,18 @@ def __init__(
identifiers=device_entry.identifiers,
)

self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
self._attr_has_entity_name = True

if coordinator.source_entity_id and not coordinator.device_id:
self._attr_translation_placeholders = {"device_name": coordinator.device_name + " "}
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
elif coordinator.source_entity_id and coordinator.device_id:
source_entity_domain, source_object_id = split_entity_id(coordinator.source_entity_id)
self._attr_translation_placeholders = {"device_name": coordinator.source_entity_name + " "}
self.entity_id = f"binary_sensor.{source_object_id}_{description.key}"
else:
self._attr_translation_placeholders = {"device_name": ""}
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"

self._template = template
self._state: bool | None = None
Expand Down Expand Up @@ -497,9 +514,21 @@ def __init__(
device_registry = dr.async_get(hass)

self.coordinator = coordinator
self._attr_has_entity_name = True

if coordinator.source_entity_id and not coordinator.device_id:
self._attr_translation_placeholders = {"device_name": coordinator.device_name + " "}
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"
elif coordinator.source_entity_id and coordinator.device_id:
source_entity_domain, source_object_id = split_entity_id(coordinator.source_entity_id)
self._attr_translation_placeholders = {"device_name": coordinator.source_entity_name + " "}
self.entity_id = f"binary_sensor.{source_object_id}_{description.key}"
else:
self._attr_translation_placeholders = {"device_name": ""}
self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"

self.entity_description = description
self._attr_unique_id = unique_id
self._attr_has_entity_name = True

super().__init__(coordinator=coordinator)

Expand All @@ -511,8 +540,6 @@ def __init__(
identifiers=device_entry.identifiers,
)

self.entity_id = f"binary_sensor.{coordinator.device_name.lower()}_{description.key}"

async def async_added_to_hass(self) -> None:
"""Handle added to Hass."""

Expand All @@ -536,7 +563,7 @@ def _handle_coordinator_update(self) -> None:
STATE_UNAVAILABLE,
STATE_UNKNOWN,
]
or not isfloat(wrapped_battery_state.state)
or not validate_is_float(wrapped_battery_state.state)
):
self._attr_is_on = None
self._attr_available = False
Expand Down
Loading

0 comments on commit cde90da

Please sign in to comment.