-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add SFR Box integration #84780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add SFR Box integration #84780
Changes from all commits
ce6fa95
b482294
a728c1e
acc224d
7aa6704
3b23a7c
aea5715
7a372e5
6fea308
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """SFR Box.""" | ||
| from __future__ import annotations | ||
|
|
||
| from sfrbox_api.bridge import SFRBox | ||
| from sfrbox_api.exceptions import SFRBoxError | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_HOST | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| from homeassistant.helpers import device_registry as dr | ||
| from homeassistant.helpers.httpx_client import get_async_client | ||
|
|
||
| from .const import DOMAIN, PLATFORMS | ||
| from .coordinator import DslDataUpdateCoordinator | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up SFR box as config entry.""" | ||
| box = SFRBox(ip=entry.data[CONF_HOST], client=get_async_client(hass)) | ||
| try: | ||
| system_info = await box.system_get_info() | ||
| except SFRBoxError as err: | ||
| raise ConfigEntryNotReady( | ||
| f"Unable to connect to {entry.data[CONF_HOST]}" | ||
| ) from err | ||
| hass.data.setdefault(DOMAIN, {}) | ||
|
|
||
| device_registry = dr.async_get(hass) | ||
| device_registry.async_get_or_create( | ||
| config_entry_id=entry.entry_id, | ||
| identifiers={(DOMAIN, system_info.mac_addr)}, | ||
| name="SFR Box", | ||
| model=system_info.product_id, | ||
| sw_version=system_info.version_mainfirmware, | ||
| configuration_url=f"http://{entry.data[CONF_HOST]}", | ||
| ) | ||
|
|
||
| hass.data[DOMAIN][entry.entry_id] = { | ||
| "box": box, | ||
|
MartinHjelmare marked this conversation as resolved.
|
||
| "dsl_coordinator": DslDataUpdateCoordinator(hass, box), | ||
| } | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): | ||
| hass.data[DOMAIN].pop(entry.entry_id) | ||
| return unload_ok | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| """SFR Box config flow.""" | ||
| from __future__ import annotations | ||
|
|
||
| from sfrbox_api.bridge import SFRBox | ||
| from sfrbox_api.exceptions import SFRBoxError | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow | ||
| from homeassistant.const import CONF_HOST | ||
| from homeassistant.data_entry_flow import FlowResult | ||
| from homeassistant.helpers.httpx_client import get_async_client | ||
|
|
||
| from .const import DEFAULT_HOST, DOMAIN | ||
|
|
||
| DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_HOST, default=DEFAULT_HOST): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN): | ||
| """SFR Box config flow.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, str] | None = None | ||
| ) -> FlowResult: | ||
| """Handle a flow initialized by the user.""" | ||
| errors = {} | ||
| if user_input is not None: | ||
| try: | ||
| box = SFRBox( | ||
|
epenet marked this conversation as resolved.
|
||
| ip=user_input[CONF_HOST], | ||
| client=get_async_client(self.hass), | ||
| ) | ||
| system_info = await box.system_get_info() | ||
| except SFRBoxError: | ||
| errors["base"] = "unknown" | ||
|
epenet marked this conversation as resolved.
|
||
| else: | ||
| await self.async_set_unique_id(system_info.mac_addr) | ||
| self._abort_if_unique_id_configured() | ||
| self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) | ||
| return self.async_create_entry(title="SFR Box", data=user_input) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=DATA_SCHEMA, errors=errors | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """SFR Box constants.""" | ||
| from homeassistant.const import Platform | ||
|
|
||
| DEFAULT_HOST = "192.168.0.1" | ||
|
|
||
| DOMAIN = "sfr_box" | ||
|
|
||
| PLATFORMS = [Platform.SENSOR] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| """SFR Box coordinator.""" | ||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| from sfrbox_api.bridge import SFRBox | ||
| from sfrbox_api.models import DslInfo | ||
|
|
||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
| _SCAN_INTERVAL = timedelta(minutes=1) | ||
|
|
||
|
|
||
| class DslDataUpdateCoordinator(DataUpdateCoordinator[DslInfo]): | ||
| """Coordinator to manage data updates.""" | ||
|
|
||
| def __init__(self, hass: HomeAssistant, box: SFRBox) -> None: | ||
| """Initialize coordinator.""" | ||
| self._box = box | ||
| super().__init__(hass, _LOGGER, name="dsl", update_interval=_SCAN_INTERVAL) | ||
|
|
||
| async def _async_update_data(self) -> DslInfo: | ||
| """Update data.""" | ||
| return await self._box.dsl_get_info() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can throw an
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "domain": "sfr_box", | ||
| "name": "SFR Box", | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/sfr_box", | ||
| "requirements": ["sfrbox-api==0.0.1"], | ||
| "codeowners": ["@epenet"], | ||
| "iot_class": "local_polling", | ||
| "integration_type": "device" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| """SFR Box sensor platform.""" | ||
| from collections.abc import Callable | ||
| from dataclasses import dataclass | ||
|
|
||
| from sfrbox_api.bridge import SFRBox | ||
| from sfrbox_api.models import DslInfo, SystemInfo | ||
|
|
||
| from homeassistant.components.sensor import ( | ||
| SensorDeviceClass, | ||
| SensorEntity, | ||
| SensorEntityDescription, | ||
| SensorStateClass, | ||
| ) | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import SIGNAL_STRENGTH_DECIBELS, UnitOfDataRate | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.entity_platform import AddEntitiesCallback | ||
| from homeassistant.helpers.typing import StateType | ||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
|
|
||
| from .const import DOMAIN | ||
| from .coordinator import DslDataUpdateCoordinator | ||
|
|
||
|
|
||
| @dataclass | ||
| class SFRBoxSensorMixin: | ||
| """Mixin for SFR Box sensors.""" | ||
|
|
||
| value_fn: Callable[[DslInfo], StateType] | ||
|
|
||
|
|
||
| @dataclass | ||
| class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin): | ||
| """Description for SFR Box sensors.""" | ||
|
|
||
|
|
||
| SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription, ...] = ( | ||
| SFRBoxSensorEntityDescription( | ||
| key="linemode", | ||
| name="Line mode", | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.linemode, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="counter", | ||
| name="Counter", | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.counter, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="crc", | ||
| name="CRC", | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.crc, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="noise_down", | ||
| name="Noise down", | ||
| device_class=SensorDeviceClass.SIGNAL_STRENGTH, | ||
|
epenet marked this conversation as resolved.
|
||
| native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.noise_down, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="noise_up", | ||
| name="Noise up", | ||
| device_class=SensorDeviceClass.SIGNAL_STRENGTH, | ||
| native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.noise_up, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="attenuation_down", | ||
| name="Attenuation down", | ||
| device_class=SensorDeviceClass.SIGNAL_STRENGTH, | ||
| native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.attenuation_down, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="attenuation_up", | ||
| name="Attenuation up", | ||
| device_class=SensorDeviceClass.SIGNAL_STRENGTH, | ||
| native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.attenuation_up, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="rate_down", | ||
| name="Rate down", | ||
| device_class=SensorDeviceClass.DATA_RATE, | ||
| native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.rate_down, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="rate_up", | ||
| name="Rate up", | ||
| device_class=SensorDeviceClass.DATA_RATE, | ||
| native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.rate_up, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="line_status", | ||
| name="Line status", | ||
| device_class=SensorDeviceClass.ENUM, | ||
| options=[ | ||
| "No Defect", | ||
| "Of Frame", | ||
| "Loss Of Signal", | ||
| "Loss Of Power", | ||
| "Loss Of Signal Quality", | ||
| "Unknown", | ||
|
Comment on lines
+115
to
+120
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These should be lower case and been put into translations.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in #84977 |
||
| ], | ||
| has_entity_name=True, | ||
| value_fn=lambda x: x.line_status, | ||
| ), | ||
| SFRBoxSensorEntityDescription( | ||
| key="training", | ||
| name="Training", | ||
| device_class=SensorDeviceClass.ENUM, | ||
| options=[ | ||
| "Idle", | ||
| "G.994 Training", | ||
| "G.992 Started", | ||
| "G.922 Channel Analysis", | ||
| "G.992 Message Exchange", | ||
| "G.993 Started", | ||
| "G.993 Channel Analysis", | ||
| "G.993 Message Exchange", | ||
| "Showtime", | ||
| "Unknown", | ||
|
Comment on lines
+130
to
+139
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in #84977 |
||
| ], | ||
| has_entity_name=True, | ||
|
epenet marked this conversation as resolved.
|
||
| value_fn=lambda x: x.training, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback | ||
| ) -> None: | ||
| """Set up the sensors.""" | ||
| data = hass.data[DOMAIN][entry.entry_id] | ||
| box: SFRBox = data["box"] | ||
| system_info = await box.system_get_info() | ||
|
|
||
| entities = [ | ||
| SFRBoxSensor(data["dsl_coordinator"], description, system_info) | ||
| for description in SENSOR_TYPES | ||
| ] | ||
| async_add_entities(entities, True) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a coordinator that already has data, the True parameter seems unneeded here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The coordinator doesn't yet have data, at is it not (currently) initialised on setup.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in #85039 |
||
|
|
||
|
|
||
| class SFRBoxSensor(CoordinatorEntity[DslDataUpdateCoordinator], SensorEntity): | ||
| """SFR Box sensor.""" | ||
|
|
||
| entity_description: SFRBoxSensorEntityDescription | ||
|
|
||
| def __init__( | ||
| self, | ||
| coordinator: DslDataUpdateCoordinator, | ||
| description: SFRBoxSensorEntityDescription, | ||
| system_info: SystemInfo, | ||
| ) -> None: | ||
| """Initialize the sensor.""" | ||
| super().__init__(coordinator) | ||
| self.entity_description = description | ||
| self._attr_unique_id = f"{system_info.mac_addr}_dsl_{description.key}" | ||
| self._attr_device_info = {"identifiers": {(DOMAIN, system_info.mac_addr)}} | ||
|
|
||
| @property | ||
| def native_value(self) -> StateType: | ||
| """Return the native value of the device.""" | ||
| return self.entity_description.value_fn(self.coordinator.data) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
| "config": { | ||
| "step": { | ||
| "user": { | ||
| "data": { | ||
| "host": "[%key:common::config_flow::data::host%]" | ||
| } | ||
| } | ||
| }, | ||
| "error": { | ||
| "unknown": "[%key:common::config_flow::error::unknown%]" | ||
| }, | ||
| "abort": { | ||
| "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
| "config": { | ||
| "step": { | ||
| "user": { | ||
| "data": { | ||
| "host": "Host" | ||
| } | ||
| } | ||
| }, | ||
| "error": { | ||
| "unknown": "Unexpected error" | ||
| }, | ||
| "abort": { | ||
| "already_configured": "Device is already configured" | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The coordinator can already handle this on first request/refresh,thispart is thus not needed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
system_get_infois only called in setup - not via a coordinatorI will address this when I add "system" sensors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in #85039