Skip to content

Commit 75c872c

Browse files
authored
Add files via upload
1 parent baef17a commit 75c872c

2 files changed

Lines changed: 261 additions & 3 deletions

File tree

custom_components/keenetic_router_pro/sensor/__init__.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@
8585
KeeneticClientWifiBandSensor,
8686
KeeneticClientWifiModeSensor,
8787
)
88+
from .crypto import (
89+
KeeneticCryptoMapStateSensor,
90+
KeeneticCryptoMapIkeStateSensor,
91+
KeeneticCryptoMapRxBytesSensor,
92+
KeeneticCryptoMapTxBytesSensor,
93+
KeeneticCryptoMapRxThroughputSensor,
94+
KeeneticCryptoMapTxThroughputSensor,
95+
)
8896

8997

9098
async def async_setup_entry(
@@ -247,23 +255,53 @@ def _wan_sensor_set(wan_id: str) -> list[SensorEntity]:
247255
known_wan_ids.add(wan_id)
248256
entities.extend(_wan_sensor_set(wan_id))
249257

258+
# Per-crypto-map sensor set: one sub-device per site-to-site
259+
# IPsec tunnel. Covers the two state strings (tunnel, IKE), byte
260+
# counters and live throughput. Connected binary_sensor and the
261+
# Enabled switch live on their respective platforms.
262+
known_cmap_names: set[str] = set()
263+
264+
def _crypto_map_sensor_set(cmap_name: str) -> list[SensorEntity]:
265+
return [
266+
KeeneticCryptoMapStateSensor(coordinator, entry, cmap_name),
267+
KeeneticCryptoMapIkeStateSensor(coordinator, entry, cmap_name),
268+
KeeneticCryptoMapRxBytesSensor(coordinator, entry, cmap_name),
269+
KeeneticCryptoMapTxBytesSensor(coordinator, entry, cmap_name),
270+
KeeneticCryptoMapRxThroughputSensor(coordinator, entry, cmap_name),
271+
KeeneticCryptoMapTxThroughputSensor(coordinator, entry, cmap_name),
272+
]
273+
274+
for cmap_name in (coordinator.data.get("crypto_maps") or {}).keys():
275+
if cmap_name in known_cmap_names:
276+
continue
277+
known_cmap_names.add(cmap_name)
278+
entities.extend(_crypto_map_sensor_set(cmap_name))
279+
250280
async_add_entities(entities)
251281

252282
# New WAN interfaces may appear at runtime (LTE stick plugged in,
253283
# new WireGuard tunnel configured as uplink, PPPoE redialed on a
254284
# different interface). Mirror the binary_sensor platform and add
255285
# the per-WAN sensor set on the fly so the user doesn't need to
256-
# restart HA.
286+
# restart HA. Crypto maps added from the web UI fan out through
287+
# the same listener.
257288
@callback
258-
def _async_add_new_wans() -> None:
289+
def _async_add_new_dynamic_entities() -> None:
259290
new_entities: list[SensorEntity] = []
260291
for wan in coordinator.data.get("wan_interfaces", []) or []:
261292
wan_id = wan.get("id")
262293
if not wan_id or wan_id in known_wan_ids:
263294
continue
264295
known_wan_ids.add(wan_id)
265296
new_entities.extend(_wan_sensor_set(wan_id))
297+
for cmap_name in (coordinator.data.get("crypto_maps") or {}).keys():
298+
if cmap_name in known_cmap_names:
299+
continue
300+
known_cmap_names.add(cmap_name)
301+
new_entities.extend(_crypto_map_sensor_set(cmap_name))
266302
if new_entities:
267303
async_add_entities(new_entities)
268304

269-
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_wans))
305+
entry.async_on_unload(
306+
coordinator.async_add_listener(_async_add_new_dynamic_entities)
307+
)
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Sensors for site-to-site IPsec tunnels (`crypto map` entries).
2+
3+
One set of these is instantiated per entry in
4+
``coordinator.data["crypto_maps"]``. Each tunnel becomes its own HA
5+
sub-device (see ``utils.get_crypto_map_device_info``), mirroring the
6+
per-WAN model.
7+
8+
All sensor classes gracefully handle the "tunnel configured but not
9+
yet established" state where the router response is missing the
10+
``phase1`` and ``phase2_sa_list`` blocks entirely — in that case the
11+
counter / throughput sensors return 0 (which is accurate: no SA
12+
means no bytes) and the state sensors return whatever the router
13+
reports (``UNDEFINED`` is common).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from homeassistant.components.sensor import (
21+
SensorEntity,
22+
SensorStateClass,
23+
SensorDeviceClass,
24+
)
25+
from homeassistant.config_entries import ConfigEntry
26+
from homeassistant.const import (
27+
UnitOfInformation,
28+
UnitOfDataRate,
29+
EntityCategory,
30+
)
31+
32+
from ..coordinator import KeeneticCoordinator
33+
from ..entity import CryptoMapEntity
34+
35+
36+
class _CryptoMapSensorBase(CryptoMapEntity, SensorEntity):
37+
"""Shared base for per-crypto-map SensorEntity classes."""
38+
39+
_attr_has_entity_name = True
40+
41+
def __init__(
42+
self,
43+
coordinator: KeeneticCoordinator,
44+
entry: ConfigEntry,
45+
cmap_name: str,
46+
) -> None:
47+
CryptoMapEntity.__init__(
48+
self, coordinator, entry.entry_id, entry.title, cmap_name
49+
)
50+
51+
52+
# ---------- State sensors (diagnostic strings) ----------
53+
54+
55+
class KeeneticCryptoMapStateSensor(_CryptoMapSensorBase):
56+
"""Overall tunnel state (``UNDEFINED`` / ``CONNECTING`` /
57+
``PHASE1_ONLY`` / ``PHASE2_ESTABLISHED`` / ...).
58+
59+
This is the same field that powers the Connected binary_sensor,
60+
exposed here as a raw string for diagnostics and automation
61+
templates that need the exact state.
62+
"""
63+
64+
_attr_icon = "mdi:lan-connect"
65+
_attr_entity_category = EntityCategory.DIAGNOSTIC
66+
67+
@property
68+
def unique_id(self) -> str:
69+
return f"{self._entry_id}_cmap_{self._cmap_name}_state"
70+
71+
@property
72+
def name(self) -> str:
73+
return "Tunnel state"
74+
75+
@property
76+
def native_value(self) -> str | None:
77+
cmap = self._cmap
78+
if cmap is None:
79+
return None
80+
return cmap.get("state")
81+
82+
83+
class KeeneticCryptoMapIkeStateSensor(_CryptoMapSensorBase):
84+
"""Phase-1 / IKE state.
85+
86+
Distinct from the overall tunnel state: you can have IKE
87+
``ESTABLISHED`` while the overall state is still ``PHASE1_ONLY``
88+
because phase-2 SA negotiation failed. That's exactly the case
89+
where this sensor is most useful for troubleshooting.
90+
"""
91+
92+
_attr_icon = "mdi:key-chain-variant"
93+
_attr_entity_category = EntityCategory.DIAGNOSTIC
94+
95+
@property
96+
def unique_id(self) -> str:
97+
return f"{self._entry_id}_cmap_{self._cmap_name}_ike_state"
98+
99+
@property
100+
def name(self) -> str:
101+
return "IKE state"
102+
103+
@property
104+
def native_value(self) -> str | None:
105+
cmap = self._cmap
106+
if cmap is None:
107+
return None
108+
return cmap.get("ike_state")
109+
110+
111+
# ---------- Traffic counters & throughput ----------
112+
113+
114+
class _CryptoMapBytesBase(_CryptoMapSensorBase):
115+
"""Shared RX/TX byte counter base.
116+
117+
The counters are a sum across all phase-2 SAs of the tunnel. A
118+
phase-2 rekey resets each SA's counter to zero, which is handled
119+
by ``SensorStateClass.TOTAL_INCREASING`` — HA Statistics treats a
120+
drop as a reset rather than a negative delta.
121+
"""
122+
123+
_attr_device_class = SensorDeviceClass.DATA_SIZE
124+
_attr_state_class = SensorStateClass.TOTAL_INCREASING
125+
_attr_native_unit_of_measurement = UnitOfInformation.BYTES
126+
_attr_entity_category = EntityCategory.DIAGNOSTIC
127+
_field = "rx_bytes"
128+
129+
@property
130+
def native_value(self) -> int | None:
131+
cmap = self._cmap
132+
if cmap is None:
133+
return None
134+
v = cmap.get(self._field)
135+
if v is None:
136+
return None
137+
try:
138+
return int(v)
139+
except (TypeError, ValueError):
140+
return None
141+
142+
143+
class KeeneticCryptoMapRxBytesSensor(_CryptoMapBytesBase):
144+
_attr_icon = "mdi:download"
145+
_field = "rx_bytes"
146+
147+
@property
148+
def unique_id(self) -> str:
149+
return f"{self._entry_id}_cmap_{self._cmap_name}_rx_bytes"
150+
151+
@property
152+
def name(self) -> str:
153+
return "RX Bytes"
154+
155+
156+
class KeeneticCryptoMapTxBytesSensor(_CryptoMapBytesBase):
157+
_attr_icon = "mdi:upload"
158+
_field = "tx_bytes"
159+
160+
@property
161+
def unique_id(self) -> str:
162+
return f"{self._entry_id}_cmap_{self._cmap_name}_tx_bytes"
163+
164+
@property
165+
def name(self) -> str:
166+
return "TX Bytes"
167+
168+
169+
class _CryptoMapThroughputBase(_CryptoMapSensorBase):
170+
"""Shared RX/TX throughput base.
171+
172+
Throughput is computed in the coordinator as a delta against the
173+
previous tick, with a clamp at zero to absorb counter resets on
174+
phase-2 rekey.
175+
"""
176+
177+
_attr_device_class = SensorDeviceClass.DATA_RATE
178+
_attr_state_class = SensorStateClass.MEASUREMENT
179+
_attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND
180+
_attr_suggested_display_precision = 0
181+
_field = "rx_throughput"
182+
183+
@property
184+
def native_value(self) -> float | None:
185+
cmap = self._cmap
186+
if cmap is None:
187+
return None
188+
v = cmap.get(self._field)
189+
if v is None:
190+
return None
191+
try:
192+
return float(v)
193+
except (TypeError, ValueError):
194+
return None
195+
196+
197+
class KeeneticCryptoMapRxThroughputSensor(_CryptoMapThroughputBase):
198+
_attr_icon = "mdi:download-network"
199+
_field = "rx_throughput"
200+
201+
@property
202+
def unique_id(self) -> str:
203+
return f"{self._entry_id}_cmap_{self._cmap_name}_rx_throughput"
204+
205+
@property
206+
def name(self) -> str:
207+
return "RX Throughput"
208+
209+
210+
class KeeneticCryptoMapTxThroughputSensor(_CryptoMapThroughputBase):
211+
_attr_icon = "mdi:upload-network"
212+
_field = "tx_throughput"
213+
214+
@property
215+
def unique_id(self) -> str:
216+
return f"{self._entry_id}_cmap_{self._cmap_name}_tx_throughput"
217+
218+
@property
219+
def name(self) -> str:
220+
return "TX Throughput"

0 commit comments

Comments
 (0)