Skip to content

Commit 7b2b3e9

Browse files
authored
Add Libre Hardware Monitor integration (home-assistant#140449)
1 parent 2d5f228 commit 7b2b3e9

File tree

21 files changed

+3437
-0
lines changed

21 files changed

+3437
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ homeassistant.components.ld2410_ble.*
307307
homeassistant.components.led_ble.*
308308
homeassistant.components.lektrico.*
309309
homeassistant.components.letpot.*
310+
homeassistant.components.libre_hardware_monitor.*
310311
homeassistant.components.lidarr.*
311312
homeassistant.components.lifx.*
312313
homeassistant.components.light.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""The LibreHardwareMonitor integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.const import Platform
6+
from homeassistant.core import HomeAssistant
7+
8+
from .coordinator import (
9+
LibreHardwareMonitorConfigEntry,
10+
LibreHardwareMonitorCoordinator,
11+
)
12+
13+
PLATFORMS = [Platform.SENSOR]
14+
15+
16+
async def async_setup_entry(
17+
hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
18+
) -> bool:
19+
"""Set up LibreHardwareMonitor from a config entry."""
20+
21+
lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry)
22+
await lhm_coordinator.async_config_entry_first_refresh()
23+
24+
config_entry.runtime_data = lhm_coordinator
25+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
26+
27+
return True
28+
29+
30+
async def async_unload_entry(
31+
hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
32+
) -> bool:
33+
"""Unload a config entry."""
34+
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Config flow for LibreHardwareMonitor."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from librehardwaremonitor_api import (
9+
LibreHardwareMonitorClient,
10+
LibreHardwareMonitorConnectionError,
11+
LibreHardwareMonitorNoDevicesError,
12+
)
13+
import voluptuous as vol
14+
15+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
16+
from homeassistant.const import CONF_HOST, CONF_PORT
17+
18+
from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
CONFIG_SCHEMA = vol.Schema(
23+
{
24+
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
25+
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
26+
}
27+
)
28+
29+
30+
class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
31+
"""Handle a config flow for LibreHardwareMonitor."""
32+
33+
VERSION = 1
34+
35+
async def async_step_user(
36+
self, user_input: dict[str, Any] | None = None
37+
) -> ConfigFlowResult:
38+
"""Handle the initial step."""
39+
errors = {}
40+
41+
if user_input is not None:
42+
self._async_abort_entries_match(user_input)
43+
44+
api = LibreHardwareMonitorClient(
45+
user_input[CONF_HOST], user_input[CONF_PORT]
46+
)
47+
48+
try:
49+
_ = (await api.get_data()).main_device_ids_and_names.values()
50+
except LibreHardwareMonitorConnectionError as exception:
51+
_LOGGER.error(exception)
52+
errors["base"] = "cannot_connect"
53+
except LibreHardwareMonitorNoDevicesError:
54+
errors["base"] = "no_devices"
55+
else:
56+
return self.async_create_entry(
57+
title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
58+
data=user_input,
59+
)
60+
61+
return self.async_show_form(
62+
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
63+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Constants for the LibreHardwareMonitor integration."""
2+
3+
DOMAIN = "libre_hardware_monitor"
4+
DEFAULT_HOST = "localhost"
5+
DEFAULT_PORT = 8085
6+
DEFAULT_SCAN_INTERVAL = 10
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Coordinator for LibreHardwareMonitor integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
from types import MappingProxyType
8+
9+
from librehardwaremonitor_api import (
10+
LibreHardwareMonitorClient,
11+
LibreHardwareMonitorConnectionError,
12+
LibreHardwareMonitorNoDevicesError,
13+
)
14+
from librehardwaremonitor_api.model import (
15+
DeviceId,
16+
DeviceName,
17+
LibreHardwareMonitorData,
18+
)
19+
20+
from homeassistant.config_entries import ConfigEntry
21+
from homeassistant.const import CONF_HOST, CONF_PORT
22+
from homeassistant.core import HomeAssistant
23+
from homeassistant.helpers import device_registry as dr
24+
from homeassistant.helpers.device_registry import DeviceEntry
25+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
26+
27+
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
28+
29+
_LOGGER = logging.getLogger(__name__)
30+
31+
32+
type LibreHardwareMonitorConfigEntry = ConfigEntry[LibreHardwareMonitorCoordinator]
33+
34+
35+
class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitorData]):
36+
"""Class to manage fetching LibreHardwareMonitor data."""
37+
38+
config_entry: LibreHardwareMonitorConfigEntry
39+
40+
def __init__(
41+
self, hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
42+
) -> None:
43+
"""Initialize."""
44+
super().__init__(
45+
hass,
46+
_LOGGER,
47+
name=DOMAIN,
48+
config_entry=config_entry,
49+
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
50+
)
51+
52+
host = config_entry.data[CONF_HOST]
53+
port = config_entry.data[CONF_PORT]
54+
self._api = LibreHardwareMonitorClient(host, port)
55+
device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
56+
registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
57+
)
58+
self._previous_devices: MappingProxyType[DeviceId, DeviceName] = (
59+
MappingProxyType(
60+
{
61+
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
62+
for device in device_entries
63+
if device.identifiers and device.name
64+
}
65+
)
66+
)
67+
68+
async def _async_update_data(self) -> LibreHardwareMonitorData:
69+
try:
70+
lhm_data = await self._api.get_data()
71+
except LibreHardwareMonitorConnectionError as err:
72+
raise UpdateFailed(
73+
"LibreHardwareMonitor connection failed, will retry"
74+
) from err
75+
except LibreHardwareMonitorNoDevicesError as err:
76+
raise UpdateFailed("No sensor data available, will retry") from err
77+
78+
await self._async_handle_changes_in_devices(lhm_data.main_device_ids_and_names)
79+
80+
return lhm_data
81+
82+
async def _async_refresh(
83+
self,
84+
log_failures: bool = True,
85+
raise_on_auth_failed: bool = False,
86+
scheduled: bool = False,
87+
raise_on_entry_error: bool = False,
88+
) -> None:
89+
# we don't expect the computer to be online 24/7 so we don't want to log a connection loss as an error
90+
await super()._async_refresh(
91+
False, raise_on_auth_failed, scheduled, raise_on_entry_error
92+
)
93+
94+
async def _async_handle_changes_in_devices(
95+
self, detected_devices: MappingProxyType[DeviceId, DeviceName]
96+
) -> None:
97+
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
98+
previous_device_ids = set(self._previous_devices.keys())
99+
detected_device_ids = set(detected_devices.keys())
100+
101+
if previous_device_ids == detected_device_ids:
102+
return
103+
104+
if self.data is None:
105+
# initial update during integration startup
106+
self._previous_devices = detected_devices # type: ignore[unreachable]
107+
return
108+
109+
if orphaned_devices := previous_device_ids - detected_device_ids:
110+
_LOGGER.warning(
111+
"Device(s) no longer available, will be removed: %s",
112+
[self._previous_devices[device_id] for device_id in orphaned_devices],
113+
)
114+
device_registry = dr.async_get(self.hass)
115+
for device_id in orphaned_devices:
116+
if device := device_registry.async_get_device(
117+
identifiers={(DOMAIN, device_id)}
118+
):
119+
device_registry.async_update_device(
120+
device_id=device.id,
121+
remove_config_entry_id=self.config_entry.entry_id,
122+
)
123+
124+
if new_devices := detected_device_ids - previous_device_ids:
125+
_LOGGER.warning(
126+
"New Device(s) detected, reload integration to add them to Home Assistant: %s",
127+
[detected_devices[DeviceId(device_id)] for device_id in new_devices],
128+
)
129+
130+
self._previous_devices = detected_devices
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"domain": "libre_hardware_monitor",
3+
"name": "Libre Hardware Monitor",
4+
"codeowners": ["@Sab44"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
7+
"iot_class": "local_polling",
8+
"quality_scale": "silver",
9+
"requirements": ["librehardwaremonitor-api==1.3.1"]
10+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
rules:
2+
# Bronze
3+
action-setup:
4+
status: exempt
5+
comment: |
6+
No custom actions are defined.
7+
appropriate-polling: done
8+
brands: done
9+
common-modules: done
10+
config-flow-test-coverage: done
11+
config-flow: done
12+
dependency-transparency: done
13+
docs-actions:
14+
status: exempt
15+
comment: |
16+
No custom actions are defined.
17+
docs-high-level-description: done
18+
docs-installation-instructions: done
19+
docs-removal-instructions: done
20+
entity-event-setup:
21+
status: exempt
22+
comment: |
23+
No explicit event subscriptions.
24+
entity-unique-id: done
25+
has-entity-name: done
26+
runtime-data: done
27+
test-before-configure: done
28+
test-before-setup:
29+
status: exempt
30+
comment: |
31+
Device is expected to be offline most of the time, but needs to connect quickly once available.
32+
unique-config-entry: done
33+
# Silver
34+
action-exceptions:
35+
status: exempt
36+
comment: |
37+
No custom actions are defined.
38+
config-entry-unloading: done
39+
docs-configuration-parameters: done
40+
docs-installation-parameters: done
41+
entity-unavailable: done
42+
integration-owner: done
43+
log-when-unavailable:
44+
status: exempt
45+
comment: |
46+
Device is expected to be temporarily unavailable.
47+
parallel-updates: done
48+
reauthentication-flow:
49+
status: exempt
50+
comment: |
51+
This integration does not require authentication.
52+
test-coverage: done
53+
# Gold
54+
devices: done
55+
diagnostics: todo
56+
discovery-update-info: todo
57+
discovery: todo
58+
docs-data-update: todo
59+
docs-examples: todo
60+
docs-known-limitations: todo
61+
docs-supported-devices: todo
62+
docs-supported-functions: todo
63+
docs-troubleshooting: done
64+
docs-use-cases: todo
65+
dynamic-devices: done
66+
entity-category: todo
67+
entity-device-class: todo
68+
entity-disabled-by-default: todo
69+
entity-translations: todo
70+
exception-translations: todo
71+
icon-translations: todo
72+
reconfiguration-flow: todo
73+
repair-issues:
74+
status: exempt
75+
comment: |
76+
This integration doesn't have any cases where raising an issue is needed.
77+
stale-devices: done
78+
# Platinum
79+
async-dependency: done
80+
inject-websession: todo
81+
strict-typing: done

0 commit comments

Comments
 (0)