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/foursquare/*
homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/camera.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/switch.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:
"""Initialize Freebox config flow."""
self._host = None
self._host: str
self._port = 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 {},
)
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."""
errors = {}
errors: dict[str, str] = {}
if user_input is None:
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()
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.
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)
async def async_step_import(self, user_input=None):
async def async_step_import(self, user_input=None) -> FlowResult:
"""Import a config entry."""
return await self.async_step_user(user_input)

View File

@ -16,7 +16,13 @@ APP_DESC = {
}
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"
@ -27,7 +33,6 @@ STORAGE_VERSION = 1
CONNECTION_SENSORS_KEYS = {"rate_down", "rate_up"}
# Icons
DEVICE_ICONS = {
"freebox_delta": "mdi:television-guide",
@ -48,3 +53,20 @@ DEVICE_ICONS = {
"vg_console": "mdi:gamepad-variant",
"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",
"codeowners": ["@hacf-fr", "@Quentame"],
"config_flow": true,
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox",
"iot_class": "local_polling",
"loggers": ["freebox_api"],

View File

@ -4,14 +4,16 @@ from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from datetime import datetime
import logging
import os
from pathlib import Path
from typing import Any
from freebox_api import Freepybox
from freebox_api.api.call import Call
from freebox_api.api.home import Home
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.const import CONF_HOST, CONF_PORT
@ -27,10 +29,13 @@ from .const import (
APP_DESC,
CONNECTION_SENSORS_KEYS,
DOMAIN,
HOME_COMPATIBLE_PLATFORMS,
STORAGE_KEY,
STORAGE_VERSION,
)
_LOGGER = logging.getLogger(__name__)
async def get_api(hass: HomeAssistant, host: str) -> Freepybox:
"""Get the Freebox API."""
@ -70,11 +75,15 @@ class FreeboxRouter:
self.sensors_temperature: dict[str, int] = {}
self.sensors_connection: dict[str, float] = {}
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:
"""Update all Freebox platforms."""
await self.update_device_trackers()
await self.update_sensors()
await self.update_home_devices()
async def update_device_trackers(self) -> None:
"""Update Freebox devices."""
@ -146,6 +155,30 @@ class FreeboxRouter:
for fbx_disk in fbx_disks:
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:
"""Reboot the Freebox."""
await self._api.system.reboot()
@ -172,6 +205,11 @@ class FreeboxRouter:
"""Event specific per Freebox entry to signal new device."""
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
def signal_device_update(self) -> str:
"""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."""
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
def sensors(self) -> dict[str, Any]:
"""Return sensors."""
@ -196,3 +239,8 @@ class FreeboxRouter:
def wifi(self) -> Wifi:
"""Return the 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._router = router
self._attr_unique_id = f"{router.mac} {description.name}"
self._attr_device_info = router.device_info
@callback
def async_update_state(self) -> None:
@ -123,11 +124,6 @@ class FreeboxSensor(SensorEntity):
else:
self._attr_native_value = state
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return self._router.device_info
@callback
def async_on_demand_update(self):
"""Update state."""
@ -193,19 +189,18 @@ class FreeboxDiskSensor(FreeboxSensor):
self._disk = disk
self._partition = partition
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
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return DeviceInfo(
identifiers={(DOMAIN, self._disk["id"])},
model=self._disk["model"],
name=f"Disk {self._disk['id']}",
sw_version=self._disk["firmware"],
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, disk["id"])},
model=disk["model"],
name=f"Disk {disk['id']}",
sw_version=disk["firmware"],
via_device=(
DOMAIN,
self._router.mac,
router.mac,
),
)

View File

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

File diff suppressed because it is too large Load Diff