-
-
Notifications
You must be signed in to change notification settings - Fork 32.1k
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
base: dev
Are you sure you want to change the base?
Add Fing integration #126058
Changes from all commits
5b23ba2
5444759
70947f0
b05fabe
81ff32a
ee5e2fb
0a82943
d01a2f0
6810900
3f72f02
81d90d8
899d5f4
5f8b2e9
2a08e16
40a5223
ce8fd54
1fb1b76
214bdc4
0593355
22ef94d
60553ee
212f3f9
69788d8
6a4b841
c0a8c01
40d4d4d
12c9f1b
91649c3
5dd96bf
e26ebc6
3ad2537
400b291
c9c420c
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,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) |
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") | ||
|
||
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
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. 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, | ||
) |
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
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. I would prefer to uset he common keys from |
||
DOMAIN = "fing" |
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 | ||||
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.
Suggested change
This is already stored in |
||||
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
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. 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 |
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] = [] | ||||||||||||||||||||||||||||
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. We don't want to keep track of the 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. 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 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. You rather need to use the unique id for that. entity_ids are renamable by users 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. But the 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. I've checked the removal of the entity from the entity manager section more closely. 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. I think we can easily make this a |
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
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
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. also, isn't the |
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
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 | ||||||||||||||||||||||||||||
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. 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
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.
Suggested change
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
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. 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
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. 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
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 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() |
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"] | ||
} |
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.
This can be done with
self._abort_entries_match({AGENT_IP: user_input[AGENT_IP]})
Let's move it out of the try block