diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py new file mode 100644 index 00000000000..aabd07366b4 --- /dev/null +++ b/homeassistant/components/freebox/binary_sensor.py @@ -0,0 +1,100 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + +RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="raid_degraded", + name="degraded", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the binary sensors.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) + + binary_entities = [ + FreeboxRaidDegradedSensor(router, raid, description) + for raid in router.raids.values() + for description in RAID_SENSORS + ] + + if binary_entities: + async_add_entities(binary_entities, True) + + +class FreeboxRaidDegradedSensor(BinarySensorEntity): + """Representation of a Freebox raid sensor.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + router: FreeboxRouter, + raid: dict[str, Any], + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Freebox raid degraded sensor.""" + self.entity_description = description + self._router = router + self._attr_device_info = router.device_info + self._raid = raid + self._attr_name = f"Raid array {raid['id']} {description.name}" + self._attr_unique_id = ( + f"{router.mac} {description.key} {raid['name']} {raid['id']}" + ) + + @callback + def async_update_state(self) -> None: + """Update the Freebox Raid sensor.""" + self._raid = self._router.raids[self._raid["id"]] + + @property + def is_on(self) -> bool: + """Return true if degraded.""" + return self._raid["degraded"] + + @callback + def async_on_demand_update(self): + """Update state.""" + self.async_update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + self.async_update_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_sensor_update, + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 767cb94de48..5a7c7863b4e 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.BINARY_SENSOR, Platform.SWITCH, Platform.CAMERA, ] diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 5622da48e67..4a9c22847ae 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -72,6 +72,7 @@ class FreeboxRouter: self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} + self.raids: dict[int, dict[str, Any]] = {} self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] @@ -145,6 +146,8 @@ class FreeboxRouter: await self._update_disks_sensors() + await self._update_raids_sensors() + async_dispatcher_send(self.hass, self.signal_sensor_update) async def _update_disks_sensors(self) -> None: @@ -155,6 +158,14 @@ class FreeboxRouter: for fbx_disk in fbx_disks: self.disks[fbx_disk["id"]] = fbx_disk + async def _update_raids_sensors(self) -> None: + """Update Freebox raids.""" + # None at first request + fbx_raids: list[dict[str, Any]] = await self._api.storage.get_raids() or [] + + for fbx_raid in fbx_raids: + self.raids[fbx_raid["id"]] = fbx_raid + async def update_home_devices(self) -> None: """Update Home devices (alarm, light, sensor, switch, remote ...).""" if not self.home_granted: diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 7bf1cbfe7a4..b950d44508d 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -11,6 +11,7 @@ from .const import ( DATA_HOME_GET_NODES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, + DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, WIFI_GET_GLOBAL_CONFIG, ) @@ -56,6 +57,7 @@ def mock_router(mock_device_registry_devices): # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) + instance.storage.get_raids = AsyncMock(return_value=DATA_STORAGE_GET_RAIDS) # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 96fe96c19c5..7028366d02b 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -93,75 +93,177 @@ DATA_STORAGE_GET_DISKS = [ { "idle_duration": 0, "read_error_requests": 0, - "read_requests": 110, + "read_requests": 1815106, "spinning": True, - # "table_type": "ms-dos", API returns without dash, but codespell isn't agree - "firmware": "SC1D", - "type": "internal", - "idle": False, - "connector": 0, - "id": 0, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": True, + "connector": 2, + "id": 1000, "write_error_requests": 0, - "state": "enabled", - "write_requests": 2708929, - "total_bytes": 250050000000, - "model": "ST9250311CS", + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", "active_duration": 0, - "temp": 40, - "serial": "6VCQY907", + "temp": 30, + "serial": "ZDZLBFHC", "partitions": [ { - "fstype": "ext4", - "total_bytes": 244950000000, - "label": "Disque dur", - "id": 2, - "internal": True, + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go", + "id": 1000, + "internal": False, "fsck_result": "no_run_yet", - "state": "mounted", - "disk_id": 0, - "free_bytes": 227390000000, - "used_bytes": 5090000000, - "path": "L0Rpc3F1ZSBkdXI=", + "state": "umounted", + "disk_id": 1000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28=", } ], }, { - "idle_duration": 8290, + "idle_duration": 0, "read_error_requests": 0, - "read_requests": 2326826, - "spinning": False, - "table_type": "gpt", + "read_requests": 3622038, + "spinning": True, + "table_type": "raid", "firmware": "0001", "type": "sata", "idle": True, "connector": 0, "id": 2000, "write_error_requests": 0, - "state": "enabled", - "write_requests": 122733632, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, "total_bytes": 2000000000000, "model": "ST2000LM015-2E8174", "active_duration": 0, + "temp": 31, + "serial": "ZDZLEJXE", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go 1", + "id": 2000, + "internal": False, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 2000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28gMQ==", + } + ], + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 0, + "spinning": False, + "table_type": "superfloppy", + "firmware": "", + "type": "raid", + "idle": False, + "connector": 0, + "id": 3000, + "write_error_requests": 0, + "state": "enabled", + "write_requests": 0, + "total_bytes": 2000000000000, + "model": "", + "active_duration": 0, "temp": 0, - "serial": "WDZYJ27Q", + "serial": "", "partitions": [ { "fstype": "ext4", "total_bytes": 1960000000000, - "label": "Disque 2", - "id": 2001, + "label": "Freebox", + "id": 3000, "internal": False, "fsck_result": "no_run_yet", "state": "mounted", - "disk_id": 2000, - "free_bytes": 1880000000000, - "used_bytes": 85410000000, - "path": "L0Rpc3F1ZSAy", + "disk_id": 3000, + "free_bytes": 1730000000000, + "used_bytes": 236910000000, + "path": "L0ZyZWVib3g=", } ], }, ] +DATA_STORAGE_GET_RAIDS = [ + { + "degraded": False, + "raid_disks": 2, # Number of members that should be in this array + "next_check": 0, # Unix timestamp of next check in seconds. Might be 0 if check_interval is 0 + "sync_action": "idle", # values: idle, resync, recover, check, repair, reshape, frozen + "level": "raid1", # values: basic, raid0, raid1, raid5, raid10 + "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + "sysfs_state": "clear", # values: clear, inactive, suspended, readonly, read_auto, clean, active, write_pending, active_idle + "id": 0, + "sync_completed_pos": 0, # Current position of sync process + "members": [ + { + "total_bytes": 2000000000000, + "active_device": 1, + "id": 1000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 29, + "serial": "ZDZLBFHC", + "model": "ST2000LM015-2E8174", + }, + "role": "active", # values: active, faulty, spare, missing + "sct_erc_supported": False, + "sct_erc_enabled": False, + "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", + "device_location": "sata-internal-p2", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + }, + { + "total_bytes": 2000000000000, + "active_device": 0, + "id": 2000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 30, + "serial": "ZDZLEJXE", + "model": "ST2000LM015-2E8174", + }, + "role": "active", + "sct_erc_supported": False, + "sct_erc_enabled": False, + "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", + "device_location": "sata-internal-p0", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + }, + ], + "array_size": 2000000000000, # Size of array in bytes + "state": "running", # stopped, running, error + "sync_speed": 0, # Sync speed in bytes per second + "name": "Freebox", + "check_interval": 0, # Check interval in seconds + "disk_id": 3000, + "last_check": 1682884357, # Unix timestamp of last check in seconds + "sync_completed_end": 0, # End position of sync process: total of bytes to sync + "sync_completed_percent": 0, # Percentage of sync completion + } +] + # switch WIFI_GET_GLOBAL_CONFIG = {"enabled": True, "mac_filter_state": "disabled"} diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py new file mode 100644 index 00000000000..08ecfca3794 --- /dev/null +++ b/tests/components/freebox/test_binary_sensor.py @@ -0,0 +1,44 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from datetime import timedelta +from unittest.mock import Mock + +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from .const import DATA_STORAGE_GET_RAIDS, MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: + """Test raid array degraded binary sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state + == "off" + ) + + # Now simulate we degraded + DATA_STORAGE_GET_RAIDS_DEGRADED = deepcopy(DATA_STORAGE_GET_RAIDS) + DATA_STORAGE_GET_RAIDS_DEGRADED[0]["degraded"] = True + router().storage.get_raids.return_value = DATA_STORAGE_GET_RAIDS_DEGRADED + # Simulate an update + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + # To execute the save + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state + == "on" + )