Home Assistant Unofficial Reference 2024.12.1
switch.py
Go to the documentation of this file.
1 """Support for TPLink Omada device toggle options."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable
6 from dataclasses import dataclass
7 from functools import partial
8 from typing import Any, Generic, TypeVar
9 
10 from tplink_omada_client import OmadaSiteClient, SwitchPortOverrides
11 from tplink_omada_client.definitions import GatewayPortMode, PoEMode, PortType
12 from tplink_omada_client.devices import (
13  OmadaDevice,
14  OmadaGateway,
15  OmadaGatewayPortConfig,
16  OmadaGatewayPortStatus,
17  OmadaSwitch,
18  OmadaSwitchPortDetails,
19 )
20 from tplink_omada_client.omadasiteclient import GatewayPortSettings
21 
22 from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
23 from homeassistant.const import EntityCategory
24 from homeassistant.core import HomeAssistant, callback
25 from homeassistant.helpers.entity_platform import AddEntitiesCallback
26 
27 from . import OmadaConfigEntry
28 from .controller import OmadaGatewayCoordinator, OmadaSwitchPortCoordinator
29 from .coordinator import OmadaCoordinator
30 from .entity import OmadaDeviceEntity
31 
32 TPort = TypeVar("TPort")
33 TDevice = TypeVar("TDevice", bound="OmadaDevice")
34 TCoordinator = TypeVar("TCoordinator", bound="OmadaCoordinator[Any]")
35 
36 
38  hass: HomeAssistant,
39  config_entry: OmadaConfigEntry,
40  async_add_entities: AddEntitiesCallback,
41 ) -> None:
42  """Set up switches."""
43  controller = config_entry.runtime_data
44  omada_client = controller.omada_client
45 
46  # Naming fun. Omada switches, as in the network hardware
47  network_switches = await omada_client.get_switches()
48 
49  entities: list = []
50  for switch in [
51  ns for ns in network_switches if ns.device_capabilities.supports_poe
52  ]:
53  coordinator = controller.get_switch_port_coordinator(switch)
54  await coordinator.async_request_refresh()
55 
56  entities.extend(
57  OmadaDevicePortSwitchEntity[
58  OmadaSwitchPortCoordinator, OmadaSwitch, OmadaSwitchPortDetails
59  ](
60  coordinator,
61  switch,
62  port,
63  port.port_id,
64  desc,
65  port_name=_get_switch_port_base_name(port),
66  )
67  for port in coordinator.data.values()
68  for desc in SWITCH_PORT_DETAILS_SWITCHES
69  if desc.exists_func(switch, port)
70  )
71 
72  gateway_coordinator = controller.gateway_coordinator
73  if gateway_coordinator:
74  for gateway in gateway_coordinator.data.values():
75  entities.extend(
76  OmadaDevicePortSwitchEntity[
77  OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortStatus
78  ](gateway_coordinator, gateway, p, str(p.port_number), desc)
79  for p in gateway.port_status
80  for desc in GATEWAY_PORT_STATUS_SWITCHES
81  if desc.exists_func(gateway, p)
82  )
83  entities.extend(
84  OmadaDevicePortSwitchEntity[
85  OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortConfig
86  ](gateway_coordinator, gateway, p, str(p.port_number), desc)
87  for p in gateway.port_configs
88  for desc in GATEWAY_PORT_CONFIG_SWITCHES
89  if desc.exists_func(gateway, p)
90  )
91 
92  async_add_entities(entities)
93 
94 
95 def _get_switch_port_base_name(port: OmadaSwitchPortDetails) -> str:
96  """Get display name for a switch port."""
97 
98  if port.name == f"Port{port.port}":
99  return str(port.port)
100  return f"{port.port} ({port.name})"
101 
102 
103 @dataclass(frozen=True, kw_only=True)
105  SwitchEntityDescription, Generic[TCoordinator, TDevice, TPort]
106 ):
107  """Entity description for a toggle switch derived from a network port on an Omada device."""
108 
109  exists_func: Callable[[TDevice, TPort], bool] = lambda _, p: True
110  coordinator_update_func: Callable[[TCoordinator, TDevice, TPort], TPort | None]
111  set_func: Callable[[OmadaSiteClient, TDevice, TPort, bool], Awaitable[TPort | None]]
112  update_func: Callable[[TPort], bool]
113 
114 
115 @dataclass(frozen=True, kw_only=True)
117  OmadaDevicePortSwitchEntityDescription[
118  OmadaSwitchPortCoordinator, OmadaSwitch, OmadaSwitchPortDetails
119  ]
120 ):
121  """Entity description for a toggle switch for a feature of a Port on an Omada Switch."""
122 
123  coordinator_update_func: Callable[
124  [OmadaSwitchPortCoordinator, OmadaSwitch, OmadaSwitchPortDetails],
125  OmadaSwitchPortDetails | None,
126  ] = lambda coord, _, port: coord.data.get(port.port_id)
127 
128 
129 @dataclass(frozen=True, kw_only=True)
131  OmadaDevicePortSwitchEntityDescription[
132  OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortConfig
133  ]
134 ):
135  """Entity description for a toggle switch for a configuration of a Port on an Omada Gateway."""
136 
137  coordinator_update_func: Callable[
138  [OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortConfig],
139  OmadaGatewayPortConfig | None,
140  ] = lambda coord, device, port: next(
141  p
142  for p in coord.data[device.mac].port_configs
143  if p.port_number == port.port_number
144  )
145 
146 
147 @dataclass(frozen=True, kw_only=True)
149  OmadaDevicePortSwitchEntityDescription[
150  OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortStatus
151  ]
152 ):
153  """Entity description for a toggle switch for a status of a Port on an Omada Gateway."""
154 
155  coordinator_update_func: Callable[
156  [OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortStatus],
157  OmadaGatewayPortStatus,
158  ] = lambda coord, device, port: next(
159  p
160  for p in coord.data[device.mac].port_status
161  if p.port_number == port.port_number
162  )
163 
164 
166  client: OmadaSiteClient,
167  device: OmadaDevice,
168  port: OmadaGatewayPortStatus,
169  enable: bool,
170  ipv6: bool,
171 ) -> None:
172  # The state returned by the API is not valid. By returning None, we force a refresh
173  await client.set_gateway_wan_port_connect_state(
174  port.port_number, enable, device, ipv6=ipv6
175  )
176 
177 
178 SWITCH_PORT_DETAILS_SWITCHES: list[OmadaSwitchPortSwitchEntityDescription] = [
180  key="poe",
181  translation_key="poe_control",
182  exists_func=(
183  lambda d, p: d.device_capabilities.supports_poe and p.type != PortType.SFP
184  ),
185  set_func=(
186  lambda client, device, port, enable: client.update_switch_port(
187  device, port, overrides=SwitchPortOverrides(enable_poe=enable)
188  )
189  ),
190  update_func=lambda p: p.poe_mode != PoEMode.DISABLED,
191  entity_category=EntityCategory.CONFIG,
192  )
193 ]
194 
195 GATEWAY_PORT_STATUS_SWITCHES: list[OmadaGatewayPortStatusSwitchEntityDescription] = [
197  key="wan_connect_ipv4",
198  translation_key="wan_connect_ipv4",
199  exists_func=lambda _, p: p.mode == GatewayPortMode.WAN,
200  set_func=partial(_wan_connect_disconnect, ipv6=False),
201  update_func=lambda p: p.wan_connected,
202  ),
204  key="wan_connect_ipv6",
205  translation_key="wan_connect_ipv6",
206  exists_func=lambda _, p: p.mode == GatewayPortMode.WAN and p.wan_ipv6_enabled,
207  set_func=partial(_wan_connect_disconnect, ipv6=True),
208  update_func=lambda p: p.ipv6_wan_connected,
209  ),
210 ]
211 
212 GATEWAY_PORT_CONFIG_SWITCHES: list[OmadaGatewayPortConfigSwitchEntityDescription] = [
214  key="poe",
215  translation_key="poe_control",
216  exists_func=lambda _, port: port.poe_mode != PoEMode.NONE,
217  set_func=lambda client, device, port, enable: client.set_gateway_port_settings(
218  port.port_number, GatewayPortSettings(enable_poe=enable), device
219  ),
220  update_func=lambda p: p.poe_mode != PoEMode.DISABLED,
221  ),
222 ]
223 
224 
226  OmadaDeviceEntity[TCoordinator],
227  SwitchEntity,
228  Generic[TCoordinator, TDevice, TPort],
229 ):
230  """Generic toggle switch entity for a Netork Port of an Omada Device."""
231 
232  entity_description: OmadaDevicePortSwitchEntityDescription[
233  TCoordinator, TDevice, TPort
234  ]
235 
236  def __init__(
237  self,
238  coordinator: TCoordinator,
239  device: TDevice,
240  port_details: TPort,
241  port_id: str,
242  entity_description: OmadaDevicePortSwitchEntityDescription[
243  TCoordinator, TDevice, TPort
244  ],
245  port_name: str | None = None,
246  ) -> None:
247  """Initialize the toggle switch."""
248  super().__init__(coordinator, device)
249  self.entity_descriptionentity_description = entity_description
250  self._device_device = device
251  self._port_details_port_details = port_details
252  self._attr_unique_id_attr_unique_id = f"{device.mac}_{port_id}_{entity_description.key}"
253  self._attr_translation_placeholders_attr_translation_placeholders = {"port_name": port_name or port_id}
254 
255  async def async_added_to_hass(self) -> None:
256  """When entity is added to hass."""
257  await super().async_added_to_hass()
258  self._do_update_do_update()
259 
260  async def _async_turn_on_off(self, enable: bool) -> None:
261  updated_details = await self.entity_descriptionentity_description.set_func(
262  self.coordinator.omada_client, self._device_device, self._port_details_port_details, enable
263  )
264 
265  if updated_details:
266  self._port_details_port_details = updated_details
267  self._attr_is_on_attr_is_on = self.entity_descriptionentity_description.update_func(self._port_details_port_details)
268  else:
269  self._attr_is_on_attr_is_on = enable
270  await self.coordinator.async_request_refresh()
271  self.async_write_ha_stateasync_write_ha_state()
272 
273  async def async_turn_on(self, **kwargs: Any) -> None:
274  """Turn the entity on."""
275  await self._async_turn_on_off_async_turn_on_off(True)
276 
277  async def async_turn_off(self, **kwargs: Any) -> None:
278  """Turn the entity off."""
279  await self._async_turn_on_off_async_turn_on_off(False)
280 
281  @property
282  def available(self) -> bool:
283  """Return true if entity is available."""
284  return bool(
285  super().available
286  and self._port_details_port_details
287  and self.entity_descriptionentity_description.exists_func(self._device_device, self._port_details_port_details)
288  )
289 
290  def _do_update(self) -> None:
291  latest_port_details = self.entity_descriptionentity_description.coordinator_update_func(
292  self.coordinator, self._device_device, self._port_details_port_details
293  )
294  if latest_port_details:
295  self._port_details_port_details = latest_port_details
296  self._attr_is_on_attr_is_on = self.entity_descriptionentity_description.update_func(self._port_details_port_details)
297 
298  @callback
299  def _handle_coordinator_update(self) -> None:
300  """Handle updated data from the coordinator."""
301  self._do_update_do_update()
302  self.async_write_ha_stateasync_write_ha_state()