Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The nut component."""
2 
3 from __future__ import annotations
4 
5 from dataclasses import dataclass
6 from datetime import timedelta
7 import logging
8 from typing import TYPE_CHECKING
9 
10 from aionut import AIONUTClient, NUTError, NUTLoginError
11 
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.const import (
14  CONF_ALIAS,
15  CONF_HOST,
16  CONF_PASSWORD,
17  CONF_PORT,
18  CONF_RESOURCES,
19  CONF_SCAN_INTERVAL,
20  CONF_USERNAME,
21  EVENT_HOMEASSISTANT_STOP,
22 )
23 from homeassistant.core import Event, HomeAssistant, callback
24 from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
25 from homeassistant.helpers import device_registry as dr
26 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
27 
28 from .const import (
29  DEFAULT_SCAN_INTERVAL,
30  DOMAIN,
31  INTEGRATION_SUPPORTED_COMMANDS,
32  PLATFORMS,
33 )
34 
35 NUT_FAKE_SERIAL = ["unknown", "blank"]
36 
37 _LOGGER = logging.getLogger(__name__)
38 
39 type NutConfigEntry = ConfigEntry[NutRuntimeData]
40 
41 
42 @dataclass
44  """Runtime data definition."""
45 
46  coordinator: DataUpdateCoordinator
47  data: PyNUTData
48  unique_id: str
49  user_available_commands: set[str]
50 
51 
52 async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
53  """Set up Network UPS Tools (NUT) from a config entry."""
54 
55  # strip out the stale options CONF_RESOURCES,
56  # maintain the entry in data in case of version rollback
57  if CONF_RESOURCES in entry.options:
58  new_data = {**entry.data, CONF_RESOURCES: entry.options[CONF_RESOURCES]}
59  new_options = {k: v for k, v in entry.options.items() if k != CONF_RESOURCES}
60  hass.config_entries.async_update_entry(
61  entry, data=new_data, options=new_options
62  )
63 
64  config = entry.data
65  host = config[CONF_HOST]
66  port = config[CONF_PORT]
67 
68  alias = config.get(CONF_ALIAS)
69  username = config.get(CONF_USERNAME)
70  password = config.get(CONF_PASSWORD)
71  scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
72 
73  data = PyNUTData(host, port, alias, username, password)
74 
75  entry.async_on_unload(data.async_shutdown)
76 
77  async def async_update_data() -> dict[str, str]:
78  """Fetch data from NUT."""
79  try:
80  return await data.async_update()
81  except NUTLoginError as err:
82  raise ConfigEntryAuthFailed from err
83  except NUTError as err:
84  raise UpdateFailed(f"Error fetching UPS state: {err}") from err
85 
86  coordinator = DataUpdateCoordinator(
87  hass,
88  _LOGGER,
89  config_entry=entry,
90  name="NUT resource status",
91  update_method=async_update_data,
92  update_interval=timedelta(seconds=scan_interval),
93  always_update=False,
94  )
95 
96  # Fetch initial data so we have data when entities subscribe
97  await coordinator.async_config_entry_first_refresh()
98 
99  # Note that async_listen_once is not used here because the listener
100  # could be removed after the event is fired.
101  entry.async_on_unload(
102  hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, data.async_shutdown)
103  )
104  status = coordinator.data
105 
106  _LOGGER.debug("NUT Sensors Available: %s", status)
107 
108  entry.async_on_unload(entry.add_update_listener(_async_update_listener))
109  unique_id = _unique_id_from_status(status)
110  if unique_id is None:
111  unique_id = entry.entry_id
112 
113  if username is not None and password is not None:
114  user_available_commands = {
115  device_supported_command
116  for device_supported_command in await data.async_list_commands() or {}
117  if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS
118  }
119  else:
120  user_available_commands = set()
121 
122  entry.runtime_data = NutRuntimeData(
123  coordinator, data, unique_id, user_available_commands
124  )
125 
126  device_registry = dr.async_get(hass)
127  device_registry.async_get_or_create(
128  config_entry_id=entry.entry_id,
129  identifiers={(DOMAIN, unique_id)},
130  name=data.name.title(),
131  manufacturer=data.device_info.manufacturer,
132  model=data.device_info.model,
133  model_id=data.device_info.model_id,
134  sw_version=data.device_info.firmware,
135  serial_number=data.device_info.serial,
136  suggested_area=data.device_info.device_location,
137  )
138 
139  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
140 
141  return True
142 
143 
144 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
145  """Unload a config entry."""
146  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
147 
148 
149 async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
150  """Handle options update."""
151  await hass.config_entries.async_reload(entry.entry_id)
152 
153 
154 def _manufacturer_from_status(status: dict[str, str]) -> str | None:
155  """Find the best manufacturer value from the status."""
156  return (
157  status.get("device.mfr")
158  or status.get("ups.mfr")
159  or status.get("ups.vendorid")
160  or status.get("driver.version.data")
161  )
162 
163 
164 def _model_from_status(status: dict[str, str]) -> str | None:
165  """Find the best model value from the status."""
166  return (
167  status.get("device.model")
168  or status.get("ups.model")
169  or status.get("ups.productid")
170  )
171 
172 
173 def _firmware_from_status(status: dict[str, str]) -> str | None:
174  """Find the best firmware value from the status."""
175  return status.get("ups.firmware") or status.get("ups.firmware.aux")
176 
177 
178 def _serial_from_status(status: dict[str, str]) -> str | None:
179  """Find the best serial value from the status."""
180  serial = status.get("device.serial") or status.get("ups.serial")
181  if serial and (
182  serial.lower() in NUT_FAKE_SERIAL or serial.count("0") == len(serial.strip())
183  ):
184  return None
185  return serial
186 
187 
188 def _unique_id_from_status(status: dict[str, str]) -> str | None:
189  """Find the best unique id value from the status."""
190  serial = _serial_from_status(status)
191  # We must have a serial for this to be unique
192  if not serial:
193  return None
194 
195  manufacturer = _manufacturer_from_status(status)
196  model = _model_from_status(status)
197 
198  unique_id_group = []
199  if manufacturer:
200  unique_id_group.append(manufacturer)
201  if model:
202  unique_id_group.append(model)
203  if serial:
204  unique_id_group.append(serial)
205  return "_".join(unique_id_group)
206 
207 
208 @dataclass
210  """Device information for NUT."""
211 
212  manufacturer: str | None = None
213  model: str | None = None
214  model_id: str | None = None
215  firmware: str | None = None
216  serial: str | None = None
217  device_location: str | None = None
218 
219 
220 class PyNUTData:
221  """Stores the data retrieved from NUT.
222 
223  For each entity to use, acts as the single point responsible for fetching
224  updates from the server.
225  """
226 
227  def __init__(
228  self,
229  host: str,
230  port: int,
231  alias: str | None,
232  username: str | None,
233  password: str | None,
234  persistent: bool = True,
235  ) -> None:
236  """Initialize the data object."""
237 
238  self._host_host = host
239  self._alias_alias = alias
240 
241  self._client_client = AIONUTClient(self._host_host, port, username, password, 5, persistent)
242  self.ups_listups_list: dict[str, str] | None = None
243  self._status_status: dict[str, str] | None = None
244  self._device_info_device_info: NUTDeviceInfo | None = None
245 
246  @property
247  def status(self) -> dict[str, str] | None:
248  """Get latest update if throttle allows. Return status."""
249  return self._status_status
250 
251  @property
252  def name(self) -> str:
253  """Return the name of the ups."""
254  return self._alias_alias or f"Nut-{self._host}"
255 
256  @property
257  def device_info(self) -> NUTDeviceInfo:
258  """Return the device info for the ups."""
259  return self._device_info_device_info or NUTDeviceInfo()
260 
261  async def _async_get_alias(self) -> str | None:
262  """Get the ups alias from NUT."""
263  if not (ups_list := await self._client_client.list_ups()):
264  _LOGGER.error("Empty list while getting NUT ups aliases")
265  return None
266  self.ups_listups_list = ups_list
267  return list(ups_list)[0]
268 
269  def _get_device_info(self) -> NUTDeviceInfo | None:
270  """Get the ups device info from NUT."""
271  if not self._status_status:
272  return None
273 
274  manufacturer = _manufacturer_from_status(self._status_status)
275  model = _model_from_status(self._status_status)
276  model_id: str | None = self._status_status.get("device.part")
277  firmware = _firmware_from_status(self._status_status)
278  serial = _serial_from_status(self._status_status)
279  device_location: str | None = self._status_status.get("device.location")
280  return NUTDeviceInfo(
281  manufacturer, model, model_id, firmware, serial, device_location
282  )
283 
284  async def _async_get_status(self) -> dict[str, str]:
285  """Get the ups status from NUT."""
286  if self._alias_alias is None:
287  self._alias_alias = await self._async_get_alias_async_get_alias()
288  if TYPE_CHECKING:
289  assert self._alias_alias is not None
290  return await self._client_client.list_vars(self._alias_alias)
291 
292  async def async_update(self) -> dict[str, str]:
293  """Fetch the latest status from NUT."""
294  self._status_status = await self._async_get_status_async_get_status()
295  if self._device_info_device_info is None:
296  self._device_info_device_info = self._get_device_info_get_device_info()
297  return self._status_status
298 
299  async def async_run_command(self, command_name: str) -> None:
300  """Invoke instant command in UPS."""
301  if TYPE_CHECKING:
302  assert self._alias_alias is not None
303 
304  try:
305  await self._client_client.run_command(self._alias_alias, command_name)
306  except NUTError as err:
307  raise HomeAssistantError(
308  f"Error running command {command_name}, {err}"
309  ) from err
310 
311  async def async_list_commands(self) -> set[str] | None:
312  """Fetch the list of supported commands."""
313  if TYPE_CHECKING:
314  assert self._alias_alias is not None
315 
316  try:
317  return await self._client_client.list_commands(self._alias_alias)
318  except NUTError as err:
319  _LOGGER.error("Error retrieving supported commands %s", err)
320  return None
321 
322  @callback
323  def async_shutdown(self, _: Event | None = None) -> None:
324  """Shutdown the client connection."""
325  self._client_client.shutdown()
dict[str, str] async_update(self)
Definition: __init__.py:292
None __init__(self, str host, int port, str|None alias, str|None username, str|None password, bool persistent=True)
Definition: __init__.py:235
NUTDeviceInfo|None _get_device_info(self)
Definition: __init__.py:269
set[str]|None async_list_commands(self)
Definition: __init__.py:311
dict[str, str] _async_get_status(self)
Definition: __init__.py:284
None async_shutdown(self, Event|None _=None)
Definition: __init__.py:323
dict[str, str]|None status(self)
Definition: __init__.py:247
None async_run_command(self, str command_name)
Definition: __init__.py:299
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str|None _serial_from_status(dict[str, str] status)
Definition: __init__.py:178
str|None _manufacturer_from_status(dict[str, str] status)
Definition: __init__.py:154
None _async_update_listener(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:149
str|None _firmware_from_status(dict[str, str] status)
Definition: __init__.py:173
bool async_setup_entry(HomeAssistant hass, NutConfigEntry entry)
Definition: __init__.py:52
str|None _unique_id_from_status(dict[str, str] status)
Definition: __init__.py:188
str|None _model_from_status(dict[str, str] status)
Definition: __init__.py:164
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:144
None run_command(argparse.Namespace args)
Definition: auth.py:55