Skip to content
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 Fing integration #126058

Draft
wants to merge 33 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5b23ba2
Add Fing integration
Lorenzo-Gasparini Aug 30, 2024
5444759
Implementation of Fing API (to be moved to PyPI). Major adjustments t…
Lorenzo-Gasparini Sep 2, 2024
70947f0
Entities are now dinamically added and removed based on the unit resp…
Lorenzo-Gasparini Sep 3, 2024
b05fabe
Remove local API implementation. Used PyPI API. Added dependency into…
Lorenzo-Gasparini Sep 3, 2024
81ff32a
Add Fing integration
Lorenzo-Gasparini Aug 30, 2024
ee5e2fb
Implementation of Fing API (to be moved to PyPI). Major adjustments t…
Lorenzo-Gasparini Sep 2, 2024
0a82943
Entities are now dinamically added and removed based on the unit resp…
Lorenzo-Gasparini Sep 3, 2024
d01a2f0
Remove local API implementation. Used PyPI API. Added dependency into…
Lorenzo-Gasparini Sep 3, 2024
6810900
Merge branch 'fing-integration' of https://github.com/fingltd/ha-fing…
Lorenzo-Gasparini Sep 3, 2024
3f72f02
Config flow adjustments. Added pytests.
Lorenzo-Gasparini Sep 5, 2024
81d90d8
Strings adjustments. Major refactor to Config Flow.
Lorenzo-Gasparini Sep 6, 2024
899d5f4
Minor adjustment to const.py file
Lorenzo-Gasparini Sep 6, 2024
5f8b2e9
Merge branch 'home-assistant:dev' into fing-integration
Lorenzo-Gasparini Sep 6, 2024
2a08e16
Removed empty field from manifest.json
Lorenzo-Gasparini Sep 6, 2024
40a5223
Merge branch 'home-assistant:dev' into fing-integration
Lorenzo-Gasparini Sep 16, 2024
ce8fd54
strings fixes. const file fixes. Config_flow adjustments.
Lorenzo-Gasparini Sep 20, 2024
1fb1b76
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Sep 24, 2024
214bdc4
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Oct 2, 2024
0593355
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Oct 4, 2024
22ef94d
Merge branch 'home-assistant:dev' into fing-integration
Lorenzo-Gasparini Oct 22, 2024
60553ee
Adjustments to config_flow and __init__ files.
Lorenzo-Gasparini Oct 22, 2024
212f3f9
Adjustment to coordinator. Moved the FingDataFetcher inside FingDataU…
Lorenzo-Gasparini Oct 23, 2024
69788d8
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Nov 11, 2024
6a4b841
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Nov 25, 2024
c0a8c01
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Nov 28, 2024
40d4d4d
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Dec 5, 2024
12c9f1b
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Jan 7, 2025
91649c3
Create quality_scale.yaml
Lorenzo-Gasparini Jan 7, 2025
5dd96bf
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Jan 7, 2025
e26ebc6
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Jan 8, 2025
3ad2537
Update quality_scale.yaml
Lorenzo-Gasparini Jan 8, 2025
400b291
Update quality_scale.yaml
Lorenzo-Gasparini Jan 8, 2025
c9c420c
Merge branch 'dev' into fing-integration
Lorenzo-Gasparini Jan 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/fing/ @Lorenzo-Gasparini
/tests/components/fing/ @Lorenzo-Gasparini
/homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
Expand Down
45 changes: 45 additions & 0 deletions homeassistant/components/fing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""The Fing integration."""

from __future__ import annotations

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError

from .coordinator import FingDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.DEVICE_TRACKER]

type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]


async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
"""Set up the Fing component."""

coordinator = FingDataUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()

if coordinator.data.network_id is None:
_LOGGER.warning(
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
)
raise ConfigEntryError(
"The Agent's API version is outdated. Please update the agent to the latest version."
)

config_entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

return True


async def async_unload_entry(
hass: HomeAssistant, config_entry: FingConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
101 changes: 101 additions & 0 deletions homeassistant/components/fing/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Config flow file."""

import logging
from typing import Any

from fing_agent_api import FingAgent
import httpx
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import HomeAssistant

from .const import AGENT_IP, AGENT_KEY, AGENT_PORT, DOMAIN

_LOGGER = logging.getLogger(__name__)


def _get_data_schema(
hass: HomeAssistant, user_input: dict[str, Any] | None = None
) -> vol.Schema:
"""Get a schema with default values."""

if user_input is None:
return vol.Schema(
{
vol.Required(AGENT_IP): str,
vol.Required(AGENT_PORT, default="49090"): str,
vol.Required(AGENT_KEY): str,
}
)

return vol.Schema(
{
vol.Required(AGENT_IP, default=user_input.get(AGENT_IP)): str,
vol.Required(AGENT_PORT, default=user_input.get(AGENT_PORT)): str,
vol.Required(AGENT_KEY, default=user_input.get(AGENT_KEY)): str,
}
)


class FingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Fing config flow."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up user step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}

if user_input is not None:
try:
existing_entries = [
entry
for entry in self._async_current_entries()
if entry.data.get(AGENT_IP) == user_input[AGENT_IP]
]
if existing_entries:
return self.async_abort(reason="already_configured")
Comment on lines +55 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be done with self._abort_entries_match({AGENT_IP: user_input[AGENT_IP]})

Let's move it out of the try block


fing_api = FingAgent(
user_input[AGENT_IP], user_input[AGENT_PORT], user_input[AGENT_KEY]
)
response = await fing_api.get_devices()
if response.network_id is not None:
return self.async_create_entry(
title=f"Fing Agent {user_input.get(AGENT_IP)}", data=user_input
)

errors["base"] = "api_version_error"
Comment on lines +67 to +72
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only have things in the try block that can raise

except httpx.NetworkError as _:
errors["base"] = "cannot_connect"
except httpx.TimeoutException as _:
errors["base"] = "timeout_connect"
except httpx.HTTPStatusError as exception:
description_placeholders["message"] = (
f"{exception.response.status_code} - {exception.response.reason_phrase}"
)
if exception.response.status_code == 401:
errors["base"] = "invalid_api_key"
else:
errors["base"] = "http_status_error"
except httpx.InvalidURL as _:
errors["base"] = "url_error"
except (
httpx.HTTPError,
httpx.InvalidURL,
httpx.CookieConflict,
httpx.StreamError,
Exception,
) as _:
errors["base"] = "unexpected_error"

return self.async_show_form(
step_id="user",
data_schema=_get_data_schema(self.hass, user_input),
errors=errors,
description_placeholders=description_placeholders,
)
6 changes: 6 additions & 0 deletions homeassistant/components/fing/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Const for the Fing integration."""

AGENT_IP = "agent_ip"
AGENT_PORT = "agent_port"
AGENT_KEY = "agent_key"
Comment on lines +3 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to uset he common keys from homeassistant.const like CONF_IP and CONF_PORT

DOMAIN = "fing"
69 changes: 69 additions & 0 deletions homeassistant/components/fing/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""DataUpdateCoordinator for Fing integration."""

from datetime import timedelta
import logging

from fing_agent_api import FingAgent
from fing_agent_api.models import Device
import httpx

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import AGENT_IP, AGENT_KEY, AGENT_PORT, DOMAIN

_LOGGER = logging.getLogger(__name__)


class FingDataObject:
"""Fing Data Object."""

def __init__(
self, network_id: str | None = None, devices: dict[str, Device] | None = None
) -> None:
"""Initialize FingDataObject."""
self.network_id = network_id
self.devices = devices if devices is not None else {}


class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
"""Class to manage fetching data from Fing Agent."""

def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize global Fing updater."""
self._hass = hass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._hass = hass

This is already stored in self.hass

self._fing = FingAgent(
config_entry.data[AGENT_IP],
int(config_entry.data[AGENT_PORT]),
config_entry.data[AGENT_KEY],
)
update_interval = timedelta(seconds=30)
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)

async def _async_update_data(self) -> FingDataObject:
"""Fetch data from Fing Agent."""
try:
response = await self._fing.get_devices()
return FingDataObject(
response.network_id, {device.mac: device for device in response.devices}
)
Comment on lines +48 to +50
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only have things in the try block that can raise

except httpx.NetworkError as err:
raise UpdateFailed("Failed to connect") from err
except httpx.TimeoutException as err:
raise UpdateFailed("Timeout establishing connection") from err
except httpx.HTTPStatusError as err:
if err.response.status_code == 401:
raise UpdateFailed("Invalid API key") from err
raise UpdateFailed(
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
) from err
except httpx.InvalidURL as err:
raise UpdateFailed("Invalid hostname or IP address") from err
except (
httpx.HTTPError,
httpx.InvalidURL,
httpx.CookieConflict,
httpx.StreamError,
) as err:
raise UpdateFailed("Unexpected error from HTTP request") from err
128 changes: 128 additions & 0 deletions homeassistant/components/fing/device_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Platform for Device tracker integration."""

from fing_agent_api.models import Device

from homeassistant.components.device_tracker import ScannerEntity, SourceType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from . import FingConfigEntry
from .coordinator import FingDataUpdateCoordinator
from .utils import get_icon_from_type


async def async_setup_entry(
hass: HomeAssistant,
config_entry: FingConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator = config_entry.runtime_data
tracked_devices: list[FingTrackedDevice] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to keep track of the Entity objects we created after we passed them to async_add_entities. So we should rather use something unique like the mac address to keep track of what we have added so far. Be sure to use set since you can easily add and subtract them from another.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could use a set instead of a list; however, I’m unsure whether storing only the MAC address (or something unique) instead of the entire Entity objects would work. The Entity objects are later needed to retrieve the entity_id of entities that are no longer tracked by the agent, so they can be removed (lines 50-52).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You rather need to use the unique id for that. entity_ids are renamable by users

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the async_remove function uses the entity_id, right? Is there another way to remove devices?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've checked the removal of the entity from the entity manager section more closely.
To refactor that piece of code and remove the storage of an Entity object inside a list/set I can move the entity removal inside the entity itself.
Basically, inside the _handle_coordinator_update, I can add an else clause to the existing is not null condition, and perform the removal with er.async_get(self.hass).async_remove(entity_id=self.entity_id).
However, I'm not sure if remove the entity from the manager within the entity itself is considered a good practice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can easily make this a set now with the code you have down below


entity_registry = er.async_get(hass)

@callback
def add_entities() -> None:
new_entities = [
FingTrackedDevice(coordinator, device)
for device in coordinator.data.devices.values()
]
Comment on lines +29 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, isn't the coordinator.data.devices.keys() already a list of all the macs?


new_ent_unique_ids = {entity.unique_id for entity in new_entities}
prev_ent_unique_ids = {entity.unique_id for entity in tracked_devices}

entities_to_remove = [
entity
for entity in tracked_devices
if entity.unique_id not in new_ent_unique_ids
]

entities_to_add = [
entity
for entity in new_entities
if entity.unique_id not in prev_ent_unique_ids
]

# Removes all the entities that are no more tracked by the agent
for entity in entities_to_remove:
entity_registry.async_remove(entity.entity_id)
tracked_devices.remove(entity)

# Adds all the new entities tracked by the agent
async_add_entities(entities_to_add)
tracked_devices.extend(entities_to_add)

add_entities()
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))


class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
"""Represent a tracked device."""

_attr_has_entity_name = True

def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
"""Set up FingDevice entity."""
super().__init__(coordinator)
self._mac = device.mac
self._device = coordinator.data.devices[device.mac]
self._network_id = coordinator.data.network_id
self._attr_name = self._device.name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you maybe share a screenshot of what you exactly see?


@property
def mac_address(self) -> str:
"""Return mac_address."""
return self._mac
Comment on lines +70 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._mac = device.mac
self._device = coordinator.data.devices[device.mac]
self._network_id = coordinator.data.network_id
self._attr_name = self._device.name
@property
def mac_address(self) -> str:
"""Return mac_address."""
return self._mac
self._attr_mac_address = device.mac
self._device = coordinator.data.devices[device.mac]
self._network_id = coordinator.data.network_id
self._attr_name = self._device.name

Using shorthands for these is cleaner. You can do the same for unique_id and icon


@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return f"{self._network_id}-{self.mac_address}"
Lorenzo-Gasparini marked this conversation as resolved.
Show resolved Hide resolved

@property
def icon(self) -> str | None:
"""Return the icon."""
return get_icon_from_type(self._device.type)

@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._device.ip[0] if self._device.ip else None

@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return the attributes."""
attrs: dict[str, str] = {}
if self._device.type:
attrs["type"] = self._device.type
if self._device.make:
attrs["make"] = self._device.make
if self._device.model:
attrs["model"] = self._device.model
return attrs
Comment on lines +95 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I asked this before but I wanted to be sure, are you planning on adding more entities for the devices in the future?


@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
return True
Comment on lines +107 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can also be shorthand


@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return self._device.active

@property
def source_type(self) -> SourceType:
"""Return source type."""
return SourceType.ROUTER
Comment on lines +117 to +120
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the default for a ScannerEntity


@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
updated_device_data = self.coordinator.data.devices.get(self._mac)
if updated_device_data is not None:
self._device = updated_device_data
self.async_write_ha_state()
9 changes: 9 additions & 0 deletions homeassistant/components/fing/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"domain": "fing",
"name": "Fing",
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing_unit",
"iot_class": "local_polling",
"requirements": ["fing_agent_api==1.0.1"]
}
Loading
Loading