Add camera platform to Freebox (#88104)

* Add Freebox cameras

* Apply suggestions from code review

add code corrections after PR review

Co-authored-by: Quentame <polletquentin74@me.com>

* Update base_class.py

* add some code syntax corrections add unit tests

* add unit tests

* add syntax changes

* Update homeassistant/components/freebox/router.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/router.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/base_class.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/router.py

Co-authored-by: Quentame <polletquentin74@me.com>

* clear code  and add minor changes

* correct syntax error and check home granted access

* typing functions

* Update tests/components/freebox/conftest.py

don't needed, and will fix tests.

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Rename _volume_micro variable

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Use const not literal

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

set to true not needed

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

use _attr_supported_features instead _supported_features

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

overload the entity with command_flip property and set_flip not needed

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Cameras does not default to False,

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

delete this function because is not needed

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Co-authored-by: Quentame <polletquentin74@me.com>

* consts,  rollback _command flip is protected var

* VALUE_NOT_SET does not exists anymore

* Use HOME_COMPATIBLE_PLATFORMS

* Rename FreeboxHomeBaseClass to FreeboxHomeEntity

* Update Freebox Home comment

* Use CATEGORY_TO_MODEL to set model attr of FreeboxHomeEntity

* Use Home API from the router

* Add SERVICE_FLIP const

* Use SERVICE_FLIP const

* Fix typo in HOME_COMPATIBLE_PLATFORMS

* fix somme code issues

* use SERVICE_FLIP (lost in merge)

* use _attr_device_info

* clear code

* HOME_COMPATIBLE_PLATFORMS is a list

* Update homeassistant/components/freebox/home_base.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/home_base.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/config_flow.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/home_base.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/home_base.py

Co-authored-by: Quentame <polletquentin74@me.com>

* clear config_flow permission

* Update homeassistant/components/freebox/home_base.py

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Co-authored-by: Quentame <polletquentin74@me.com>

* add untested files to. coveragerc

* clear unused attributes

* add not tested file camera.py

* clear unusued const

* add extra_state_attributes

* Update .coveragerc

Co-authored-by: Quentame <polletquentin74@me.com>

* Update homeassistant/components/freebox/camera.py

Co-authored-by: Quentame <polletquentin74@me.com>

* fetch _flip

* del flip service

* add device_info via_device

* Update .coveragerc

* Update .coveragerc

* Update .coveragerc

* Update .coveragerc

* Remove flip reference

* Fix issue on router without Home API

* Fix "Home access is not granted" log repeats every 30s

* Fix sensor device_info

---------

Co-authored-by: Quentame <polletquentin74@me.com>
This commit is contained in:
nachonam 2023-04-26 00:03:39 +02:00 committed by GitHub
parent 62bb584522
commit 2d510bfe0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1998 additions and 23 deletions

View File

@ -386,7 +386,10 @@ omit =
homeassistant/components/foscam/camera.py homeassistant/components/foscam/camera.py
homeassistant/components/foursquare/* homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/camera.py
homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/device_tracker.py
homeassistant/components/freebox/home_base.py
homeassistant/components/freebox/router.py
homeassistant/components/freebox/sensor.py homeassistant/components/freebox/sensor.py
homeassistant/components/freebox/switch.py homeassistant/components/freebox/switch.py
homeassistant/components/fritz/common.py homeassistant/components/fritz/common.py

View File

@ -0,0 +1,122 @@
"""Support for Freebox cameras."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.camera import CameraEntityFeature
from homeassistant.components.ffmpeg.camera import (
CONF_EXTRA_ARGUMENTS,
CONF_INPUT,
DEFAULT_ARGUMENTS,
FFmpegCamera,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_DETECTION, DOMAIN
from .home_base import FreeboxHomeEntity
from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up cameras."""
router = hass.data[DOMAIN][entry.unique_id]
tracked: set = set()
@callback
def update_callback():
add_entities(hass, router, async_add_entities, tracked)
router.listeners.append(
async_dispatcher_connect(hass, router.signal_home_device_new, update_callback)
)
update_callback()
entity_platform.async_get_current_platform()
@callback
def add_entities(hass: HomeAssistant, router, async_add_entities, tracked):
"""Add new cameras from the router."""
new_tracked = []
for nodeid, node in router.home_devices.items():
if (node["category"] != Platform.CAMERA) or (nodeid in tracked):
continue
new_tracked.append(FreeboxCamera(hass, router, node))
tracked.add(nodeid)
if new_tracked:
async_add_entities(new_tracked, True)
class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera):
"""Representation of a Freebox camera."""
def __init__(
self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any]
) -> None:
"""Initialize a camera."""
super().__init__(hass, router, node)
device_info = {
CONF_NAME: node["label"].strip(),
CONF_INPUT: node["props"]["Stream"],
CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS,
}
FFmpegCamera.__init__(self, hass, device_info)
self._supported_features = (
CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM
)
self._command_motion_detection = self.get_command_id(
node["type"]["endpoints"], ATTR_DETECTION
)
self._attr_extra_state_attributes = {}
self.update_node(node)
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
await self.set_home_endpoint_value(self._command_motion_detection, True)
self._attr_motion_detection_enabled = True
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection in camera."""
await self.set_home_endpoint_value(self._command_motion_detection, False)
self._attr_motion_detection_enabled = False
async def async_update_signal(self) -> None:
"""Update the camera node."""
self.update_node(self._router.home_devices[self._id])
self.async_write_ha_state()
def update_node(self, node):
"""Update params."""
self._name = node["label"].strip()
# Get status
if self._node["status"] == "active":
self._attr_is_streaming = True
else:
self._attr_is_streaming = False
# Parse all endpoints values
for endpoint in filter(
lambda x: (x["ep_type"] == "signal"), node["show_endpoints"]
):
self._attr_extra_state_attributes[endpoint["name"]] = endpoint["value"]
# Get motion detection status
self._attr_motion_detection_enabled = self._attr_extra_state_attributes[
ATTR_DETECTION
]

View File

@ -22,7 +22,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize Freebox config flow.""" """Initialize Freebox config flow."""
self._host = None self._host: str
self._port = None self._port = None
def _show_setup_form(self, user_input=None, errors=None): def _show_setup_form(self, user_input=None, errors=None):
@ -42,9 +42,9 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors or {}, errors=errors or {},
) )
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle a flow initiated by the user.""" """Handle a flow initiated by the user."""
errors = {} errors: dict[str, str] = {}
if user_input is None: if user_input is None:
return self._show_setup_form(user_input, errors) return self._show_setup_form(user_input, errors)
@ -58,7 +58,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_link() return await self.async_step_link()
async def async_step_link(self, user_input=None): async def async_step_link(self, user_input=None) -> FlowResult:
"""Attempt to link with the Freebox router. """Attempt to link with the Freebox router.
Given a configured host, will ask the user to press the button Given a configured host, will ask the user to press the button
@ -102,7 +102,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="link", errors=errors) return self.async_show_form(step_id="link", errors=errors)
async def async_step_import(self, user_input=None): async def async_step_import(self, user_input=None) -> FlowResult:
"""Import a config entry.""" """Import a config entry."""
return await self.async_step_user(user_input) return await self.async_step_user(user_input)

View File

@ -16,7 +16,13 @@ APP_DESC = {
} }
API_VERSION = "v6" API_VERSION = "v6"
PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] PLATFORMS = [
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
Platform.SWITCH,
Platform.CAMERA,
]
DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_DEVICE_NAME = "Unknown device"
@ -27,7 +33,6 @@ STORAGE_VERSION = 1
CONNECTION_SENSORS_KEYS = {"rate_down", "rate_up"} CONNECTION_SENSORS_KEYS = {"rate_down", "rate_up"}
# Icons # Icons
DEVICE_ICONS = { DEVICE_ICONS = {
"freebox_delta": "mdi:television-guide", "freebox_delta": "mdi:television-guide",
@ -48,3 +53,20 @@ DEVICE_ICONS = {
"vg_console": "mdi:gamepad-variant", "vg_console": "mdi:gamepad-variant",
"workstation": "mdi:desktop-tower-monitor", "workstation": "mdi:desktop-tower-monitor",
} }
ATTR_DETECTION = "detection"
CATEGORY_TO_MODEL = {
"pir": "F-HAPIR01A",
"camera": "F-HACAM01A",
"dws": "F-HADWS01A",
"kfb": "F-HAKFB01A",
"alarm": "F-MSEC07A",
"rts": "RTS",
"iohome": "IOHome",
}
HOME_COMPATIBLE_PLATFORMS = [
Platform.CAMERA,
]

View File

@ -0,0 +1,131 @@
"""Support for Freebox base features."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import CATEGORY_TO_MODEL, DOMAIN
from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
class FreeboxHomeEntity(Entity):
"""Representation of a Freebox base entity."""
def __init__(
self,
hass: HomeAssistant,
router: FreeboxRouter,
node: dict[str, Any],
sub_node: dict[str, Any] | None = None,
) -> None:
"""Initialize a Freebox Home entity."""
self._hass = hass
self._router = router
self._node = node
self._sub_node = sub_node
self._id = node["id"]
self._attr_name = node["label"].strip()
self._device_name = self._attr_name
self._attr_unique_id = f"{self._router.mac}-node_{self._id}"
if sub_node is not None:
self._attr_name += " " + sub_node["label"].strip()
self._attr_unique_id += "-" + sub_node["name"].strip()
self._available = True
self._firmware = node["props"].get("FwVersion")
self._manufacturer = "Freebox SAS"
self._remove_signal_update: Any
self._model = CATEGORY_TO_MODEL.get(node["category"])
if self._model is None:
if node["type"].get("inherit") == "node::rts":
self._manufacturer = "Somfy"
self._model = CATEGORY_TO_MODEL.get("rts")
elif node["type"].get("inherit") == "node::ios":
self._manufacturer = "Somfy"
self._model = CATEGORY_TO_MODEL.get("iohome")
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._id)},
manufacturer=self._manufacturer,
model=self._model,
name=self._device_name,
sw_version=self._firmware,
via_device=(
DOMAIN,
router.mac,
),
)
async def async_update_signal(self):
"""Update signal."""
self._node = self._router.home_devices[self._id]
# Update name
if self._sub_node is None:
self._attr_name = self._node["label"].strip()
else:
self._attr_name = (
self._node["label"].strip() + " " + self._sub_node["label"].strip()
)
self.async_write_ha_state()
async def set_home_endpoint_value(self, command_id: Any, value=None) -> None:
"""Set Home endpoint value."""
if command_id is None:
_LOGGER.error("Unable to SET a value through the API. Command is None")
return
await self._router.home.set_home_endpoint_value(
self._id, command_id, {"value": value}
)
def get_command_id(self, nodes, name) -> int | None:
"""Get the command id."""
node = next(
filter(lambda x: (x["name"] == name), nodes),
None,
)
if not node:
_LOGGER.warning("The Freebox Home device has no value for: %s", name)
return None
return node["id"]
async def async_added_to_hass(self):
"""Register state update callback."""
self.remove_signal_update(
async_dispatcher_connect(
self._hass,
self._router.signal_home_device_update,
self.async_update_signal,
)
)
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
self._remove_signal_update()
def remove_signal_update(self, dispacher: Any):
"""Register state update callback."""
self._remove_signal_update = dispacher
def get_value(self, ep_type, name):
"""Get the value."""
node = next(
filter(
lambda x: (x["name"] == name and x["ep_type"] == ep_type),
self._node["show_endpoints"],
),
None,
)
if not node:
_LOGGER.warning(
"The Freebox Home device has no node for: " + ep_type + "/" + name
)
return None
return node.get("value")

View File

@ -3,6 +3,7 @@
"name": "Freebox", "name": "Freebox",
"codeowners": ["@hacf-fr", "@Quentame"], "codeowners": ["@hacf-fr", "@Quentame"],
"config_flow": true, "config_flow": true,
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox", "documentation": "https://www.home-assistant.io/integrations/freebox",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["freebox_api"], "loggers": ["freebox_api"],

View File

@ -4,14 +4,16 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from freebox_api import Freepybox from freebox_api import Freepybox
from freebox_api.api.call import Call from freebox_api.api.call import Call
from freebox_api.api.home import Home
from freebox_api.api.wifi import Wifi from freebox_api.api.wifi import Wifi
from freebox_api.exceptions import NotOpenError from freebox_api.exceptions import HttpRequestError, NotOpenError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PORT
@ -27,10 +29,13 @@ from .const import (
APP_DESC, APP_DESC,
CONNECTION_SENSORS_KEYS, CONNECTION_SENSORS_KEYS,
DOMAIN, DOMAIN,
HOME_COMPATIBLE_PLATFORMS,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VERSION, STORAGE_VERSION,
) )
_LOGGER = logging.getLogger(__name__)
async def get_api(hass: HomeAssistant, host: str) -> Freepybox: async def get_api(hass: HomeAssistant, host: str) -> Freepybox:
"""Get the Freebox API.""" """Get the Freebox API."""
@ -70,11 +75,15 @@ class FreeboxRouter:
self.sensors_temperature: dict[str, int] = {} self.sensors_temperature: dict[str, int] = {}
self.sensors_connection: dict[str, float] = {} self.sensors_connection: dict[str, float] = {}
self.call_list: list[dict[str, Any]] = [] self.call_list: list[dict[str, Any]] = []
self.home_granted = True
self.home_devices: dict[str, Any] = {}
self.listeners: list[dict[str, Any]] = []
async def update_all(self, now: datetime | None = None) -> None: async def update_all(self, now: datetime | None = None) -> None:
"""Update all Freebox platforms.""" """Update all Freebox platforms."""
await self.update_device_trackers() await self.update_device_trackers()
await self.update_sensors() await self.update_sensors()
await self.update_home_devices()
async def update_device_trackers(self) -> None: async def update_device_trackers(self) -> None:
"""Update Freebox devices.""" """Update Freebox devices."""
@ -146,6 +155,30 @@ class FreeboxRouter:
for fbx_disk in fbx_disks: for fbx_disk in fbx_disks:
self.disks[fbx_disk["id"]] = fbx_disk self.disks[fbx_disk["id"]] = fbx_disk
async def update_home_devices(self) -> None:
"""Update Home devices (alarm, light, sensor, switch, remote ...)."""
if not self.home_granted:
return
try:
home_nodes: list[Any] = await self.home.get_home_nodes() or []
except HttpRequestError:
self.home_granted = False
_LOGGER.warning("Home access is not granted")
return
new_device = False
for home_node in home_nodes:
if home_node["category"] in HOME_COMPATIBLE_PLATFORMS:
if self.home_devices.get(home_node["id"]) is None:
new_device = True
self.home_devices[home_node["id"]] = home_node
async_dispatcher_send(self.hass, self.signal_home_device_update)
if new_device:
async_dispatcher_send(self.hass, self.signal_home_device_new)
async def reboot(self) -> None: async def reboot(self) -> None:
"""Reboot the Freebox.""" """Reboot the Freebox."""
await self._api.system.reboot() await self._api.system.reboot()
@ -172,6 +205,11 @@ class FreeboxRouter:
"""Event specific per Freebox entry to signal new device.""" """Event specific per Freebox entry to signal new device."""
return f"{DOMAIN}-{self._host}-device-new" return f"{DOMAIN}-{self._host}-device-new"
@property
def signal_home_device_new(self) -> str:
"""Event specific per Freebox entry to signal new home device."""
return f"{DOMAIN}-{self._host}-home-device-new"
@property @property
def signal_device_update(self) -> str: def signal_device_update(self) -> str:
"""Event specific per Freebox entry to signal updates in devices.""" """Event specific per Freebox entry to signal updates in devices."""
@ -182,6 +220,11 @@ class FreeboxRouter:
"""Event specific per Freebox entry to signal updates in sensors.""" """Event specific per Freebox entry to signal updates in sensors."""
return f"{DOMAIN}-{self._host}-sensor-update" return f"{DOMAIN}-{self._host}-sensor-update"
@property
def signal_home_device_update(self) -> str:
"""Event specific per Freebox entry to signal update in home devices."""
return f"{DOMAIN}-{self._host}-home-device-update"
@property @property
def sensors(self) -> dict[str, Any]: def sensors(self) -> dict[str, Any]:
"""Return sensors.""" """Return sensors."""
@ -196,3 +239,8 @@ class FreeboxRouter:
def wifi(self) -> Wifi: def wifi(self) -> Wifi:
"""Return the wifi.""" """Return the wifi."""
return self._api.wifi return self._api.wifi
@property
def home(self) -> Home:
"""Return the home."""
return self._api.home

View File

@ -113,6 +113,7 @@ class FreeboxSensor(SensorEntity):
self.entity_description = description self.entity_description = description
self._router = router self._router = router
self._attr_unique_id = f"{router.mac} {description.name}" self._attr_unique_id = f"{router.mac} {description.name}"
self._attr_device_info = router.device_info
@callback @callback
def async_update_state(self) -> None: def async_update_state(self) -> None:
@ -123,11 +124,6 @@ class FreeboxSensor(SensorEntity):
else: else:
self._attr_native_value = state self._attr_native_value = state
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return self._router.device_info
@callback @callback
def async_on_demand_update(self): def async_on_demand_update(self):
"""Update state.""" """Update state."""
@ -193,19 +189,18 @@ class FreeboxDiskSensor(FreeboxSensor):
self._disk = disk self._disk = disk
self._partition = partition self._partition = partition
self._attr_name = f"{partition['label']} {description.name}" self._attr_name = f"{partition['label']} {description.name}"
self._attr_unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" self._attr_unique_id = (
f"{router.mac} {description.key} {disk['id']} {partition['id']}"
)
@property self._attr_device_info = DeviceInfo(
def device_info(self) -> DeviceInfo: identifiers={(DOMAIN, disk["id"])},
"""Return the device information.""" model=disk["model"],
return DeviceInfo( name=f"Disk {disk['id']}",
identifiers={(DOMAIN, self._disk["id"])}, sw_version=disk["firmware"],
model=self._disk["model"],
name=f"Disk {self._disk['id']}",
sw_version=self._disk["firmware"],
via_device=( via_device=(
DOMAIN, DOMAIN,
self._router.mac, router.mac,
), ),
) )

View File

@ -8,6 +8,7 @@ from homeassistant.helpers import device_registry as dr
from .const import ( from .const import (
DATA_CALL_GET_CALLS_LOG, DATA_CALL_GET_CALLS_LOG,
DATA_CONNECTION_GET_STATUS, DATA_CONNECTION_GET_STATUS,
DATA_HOME_GET_NODES,
DATA_LAN_GET_HOSTS_LIST, DATA_LAN_GET_HOSTS_LIST,
DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_DISKS,
DATA_SYSTEM_GET_CONFIG, DATA_SYSTEM_GET_CONFIG,
@ -55,6 +56,8 @@ def mock_router(mock_device_registry_devices):
# sensor # sensor
instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) 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_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS)
# home devices
instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES)
instance.connection.get_status = AsyncMock( instance.connection.get_status = AsyncMock(
return_value=DATA_CONNECTION_GET_STATUS return_value=DATA_CONNECTION_GET_STATUS
) )

File diff suppressed because it is too large Load Diff