Skip to content

Commit 029b99d

Browse files
authored
Merge pull request #60 from meshcore-dev/2.1.5
2.1.5
2 parents f6ed98d + 227cda1 commit 029b99d

File tree

8 files changed

+224
-41
lines changed

8 files changed

+224
-41
lines changed

custom_components/meshcore/const.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
CONF_REPEATER_UPDATE_INTERVAL: Final = "update_interval"
5757
CONF_REPEATER_TELEMETRY_ENABLED: Final = "telemetry_enabled"
5858
DEFAULT_REPEATER_UPDATE_INTERVAL: Final = 900 # 15 minutes in seconds
59-
MAX_REPEATER_FAILURES_BEFORE_LOGIN: Final = 3 # After this many failures, try login
59+
MAX_REPEATER_FAILURES_BEFORE_LOGIN: Final = 5 # After this many failures, try login
6060

6161
# Client tracking constants
6262
CONF_TRACKED_CLIENTS: Final = "tracked_clients"
@@ -77,8 +77,6 @@
7777
REPEATER_BACKOFF_BASE: Final = 2 # Base multiplier for exponential backoff
7878
REPEATER_BACKOFF_MAX_MULTIPLIER: Final = 120 # Maximum backoff multiplier (10 minutes when * 5 seconds)
7979

80-
# Login refresh interval for telemetry-enabled repeaters
81-
REPEATER_LOGIN_REFRESH_INTERVAL: Final = 10800 # 3 hours in seconds
8280

8381
# Generic battery voltage to percentage lookup table
8482
BATTERY_CURVE: Final = [

custom_components/meshcore/coordinator.py

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1414

1515
from meshcore.events import EventType
16+
from meshcore.packets import BinaryReqType
1617

1718
from .const import (
1819
CONF_NAME,
@@ -35,7 +36,6 @@
3536
CONF_SELF_TELEMETRY_ENABLED,
3637
CONF_SELF_TELEMETRY_INTERVAL,
3738
DEFAULT_SELF_TELEMETRY_INTERVAL,
38-
REPEATER_LOGIN_REFRESH_INTERVAL,
3939
)
4040
from .meshcore_api import MeshCoreAPI
4141

@@ -173,19 +173,14 @@ async def _update_repeater(self, repeater_config):
173173
# Get the current failure count
174174
failure_count = self._repeater_consecutive_failures.get(pubkey_prefix, 0)
175175

176-
# Check if we need to login (initial login, periodic refresh, or after failures)
176+
# Check if we need to login (initial login or after failures)
177177
last_login_time = self._repeater_login_times.get(pubkey_prefix)
178-
current_time = self._current_time()
179178
needs_initial_login = last_login_time is None
180-
needs_periodic_refresh = (last_login_time is not None and
181-
current_time - last_login_time >= REPEATER_LOGIN_REFRESH_INTERVAL)
182179
needs_failure_recovery = failure_count >= MAX_REPEATER_FAILURES_BEFORE_LOGIN
183180

184-
if needs_initial_login or needs_periodic_refresh or needs_failure_recovery:
181+
if needs_initial_login or needs_failure_recovery:
185182
if needs_initial_login:
186183
self.logger.info(f"Attempting initial login to repeater {repeater_name}")
187-
elif needs_periodic_refresh:
188-
self.logger.info(f"Attempting periodic login refresh to repeater {repeater_name} (last login: {last_login_time})")
189184
else:
190185
self.logger.info(f"Attempting login to repeater {repeater_name} after {failure_count} failures")
191186

@@ -211,7 +206,7 @@ async def _update_repeater(self, repeater_config):
211206

212207
# Request status from the repeater
213208
self.logger.debug(f"Sending status request to repeater: {repeater_name} ({pubkey_prefix})")
214-
await self.api.mesh_core.commands.send_statusreq(contact)
209+
await self.api.mesh_core.commands.send_binary_req(contact, BinaryReqType.STATUS)
215210
result = await self.api.mesh_core.wait_for_event(
216211
EventType.STATUS_RESPONSE,
217212
attribute_filters={"pubkey_prefix": pubkey_prefix},
@@ -224,12 +219,23 @@ async def _update_repeater(self, repeater_config):
224219
# Increment failure count and apply backoff
225220
new_failure_count = failure_count + 1
226221
self._repeater_consecutive_failures[pubkey_prefix] = new_failure_count
227-
self._apply_repeater_backoff(pubkey_prefix, new_failure_count)
222+
223+
# Reset path after 5 failures if there's an established path
224+
if new_failure_count == 5 and contact and contact.get("out_path_len", 0) != -1:
225+
try:
226+
await self.api.mesh_core.commands.reset_path(pubkey_prefix)
227+
self.logger.info(f"Reset path for repeater {repeater_name} after 5 failures")
228+
except Exception as ex:
229+
self.logger.warning(f"Failed to reset path for repeater {repeater_name}: {ex}")
230+
231+
update_interval = repeater_config.get(CONF_REPEATER_UPDATE_INTERVAL, DEFAULT_REPEATER_UPDATE_INTERVAL)
232+
self._apply_repeater_backoff(pubkey_prefix, new_failure_count, update_interval)
228233
elif result.payload.get('uptime', 0) == 0:
229234
self.logger.warn(f"Malformed status response from repeater {repeater_name}: {result.payload}")
230235
new_failure_count = failure_count + 1
231236
self._repeater_consecutive_failures[pubkey_prefix] = new_failure_count
232-
self._apply_repeater_backoff(pubkey_prefix, new_failure_count)
237+
update_interval = repeater_config.get(CONF_REPEATER_UPDATE_INTERVAL, DEFAULT_REPEATER_UPDATE_INTERVAL)
238+
self._apply_repeater_backoff(pubkey_prefix, new_failure_count, update_interval)
233239
else:
234240
self.logger.debug(f"Successfully updated repeater {repeater_name}")
235241
# Reset failure count on success
@@ -248,23 +254,23 @@ async def _update_repeater(self, repeater_config):
248254
# Increment failure count and apply backoff
249255
new_failure_count = self._repeater_consecutive_failures.get(pubkey_prefix, 0) + 1
250256
self._repeater_consecutive_failures[pubkey_prefix] = new_failure_count
251-
self._apply_repeater_backoff(pubkey_prefix, new_failure_count)
257+
update_interval = repeater_config.get(CONF_REPEATER_UPDATE_INTERVAL, DEFAULT_REPEATER_UPDATE_INTERVAL)
258+
self._apply_repeater_backoff(pubkey_prefix, new_failure_count, update_interval)
252259
finally:
253260
# Remove this task from active tasks
254261
if pubkey_prefix in self._active_repeater_tasks:
255262
self._active_repeater_tasks.pop(pubkey_prefix)
256263

257-
def _apply_backoff(self, pubkey_prefix: str, failure_count: int, update_type: str = "repeater") -> None:
264+
def _apply_backoff(self, pubkey_prefix: str, failure_count: int, update_interval: int, update_type: str = "repeater") -> None:
258265
"""Apply exponential backoff delay for failed updates.
259266
260-
Uses DEFAULT_UPDATE_TICK as base since that's how often we check for updates.
261-
262267
Args:
263268
pubkey_prefix: The node's public key prefix
264269
failure_count: Number of consecutive failures
270+
update_interval: The configured update interval to cap the backoff at
265271
update_type: Type of update ("repeater" or "telemetry")
266272
"""
267-
backoff_delay = min(REPEATER_BACKOFF_BASE ** failure_count, REPEATER_BACKOFF_MAX_MULTIPLIER)
273+
backoff_delay = min(REPEATER_BACKOFF_BASE ** failure_count, update_interval)
268274
next_update_time = self._current_time() + backoff_delay
269275

270276
if update_type == "telemetry":
@@ -274,11 +280,12 @@ def _apply_backoff(self, pubkey_prefix: str, failure_count: int, update_type: st
274280

275281
self.logger.debug(f"Applied backoff for {update_type} {pubkey_prefix}: "
276282
f"failure_count={failure_count}, "
277-
f"delay={backoff_delay}s")
283+
f"delay={backoff_delay}s, "
284+
f"interval_cap={update_interval}s")
278285

279-
def _apply_repeater_backoff(self, pubkey_prefix: str, failure_count: int) -> None:
286+
def _apply_repeater_backoff(self, pubkey_prefix: str, failure_count: int, update_interval: int) -> None:
280287
"""Apply exponential backoff delay for failed repeater updates."""
281-
self._apply_backoff(pubkey_prefix, failure_count, "repeater")
288+
self._apply_backoff(pubkey_prefix, failure_count, update_interval, "repeater")
282289

283290
async def _update_node_telemetry(self, contact, pubkey_prefix: str, node_name: str, update_interval: int):
284291
"""Update telemetry for a node (repeater or client).
@@ -296,7 +303,7 @@ async def _update_node_telemetry(self, contact, pubkey_prefix: str, node_name: s
296303

297304
try:
298305
self.logger.debug(f"Sending telemetry request to node: {node_name} ({pubkey_prefix})")
299-
await self.api.mesh_core.commands.send_telemetry_req(contact)
306+
await self.api.mesh_core.commands.send_binary_req(contact, BinaryReqType.TELEMETRY)
300307
telemetry_result = await self.api.mesh_core.wait_for_event(
301308
EventType.TELEMETRY_RESPONSE,
302309
attribute_filters={"pubkey_prefix": pubkey_prefix},
@@ -314,14 +321,32 @@ async def _update_node_telemetry(self, contact, pubkey_prefix: str, node_name: s
314321
# Increment failure count and apply backoff
315322
new_failure_count = failure_count + 1
316323
self._telemetry_consecutive_failures[pubkey_prefix] = new_failure_count
317-
self._apply_backoff(pubkey_prefix, new_failure_count, "telemetry")
324+
325+
# Reset path after 5 failures if there's an established path
326+
if new_failure_count == 5 and contact and contact.get("out_path_len", 0) != -1:
327+
try:
328+
await self.api.mesh_core.commands.reset_path(pubkey_prefix)
329+
self.logger.info(f"Reset path for node {node_name} after 5 telemetry failures")
330+
except Exception as ex:
331+
self.logger.warning(f"Failed to reset path for node {node_name}: {ex}")
332+
333+
self._apply_backoff(pubkey_prefix, new_failure_count, update_interval, "telemetry")
318334

319335
except Exception as ex:
320336
self.logger.warn(f"Exception requesting telemetry from node {node_name}: {ex}")
321337
# Increment failure count and apply backoff
322338
new_failure_count = failure_count + 1
323339
self._telemetry_consecutive_failures[pubkey_prefix] = new_failure_count
324-
self._apply_backoff(pubkey_prefix, new_failure_count, "telemetry")
340+
341+
# Reset path after 5 failures if there's an established path
342+
if new_failure_count == 5 and contact and contact.get("out_path_len", 0) != -1:
343+
try:
344+
await self.api.mesh_core.commands.reset_path(pubkey_prefix)
345+
self.logger.info(f"Reset path for node {node_name} after 5 telemetry failures")
346+
except Exception as reset_ex:
347+
self.logger.warning(f"Failed to reset path for node {node_name}: {reset_ex}")
348+
349+
self._apply_backoff(pubkey_prefix, new_failure_count, update_interval, "telemetry")
325350
finally:
326351
# Remove this task from active telemetry tasks
327352
if pubkey_prefix in self._active_telemetry_tasks:

custom_components/meshcore/device_tracker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ async def _handle_gps_telemetry_event(self, event: Event):
5454
if not event.payload or "lpp" not in event.payload:
5555
_LOGGER.debug("No LPP data in telemetry event")
5656
return
57-
58-
pubkey_prefix = event.payload.get("pubkey_pre", "")
57+
58+
pubkey_prefix = event.payload.get("pubkey_prefix", "")
5959
lpp_data = event.payload.get("lpp", [])
6060

6161
# If no pubkey_prefix, this might be self telemetry

custom_components/meshcore/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"dependencies": ["logbook"],
66
"codeowners": ["@awolden"],
77
"loggers": ["custom_components.meshcore"],
8-
"requirements": ["meshcore>=2.1.6", "meshcore-cli>=0.0.0"],
8+
"requirements": ["meshcore>=2.1.7", "meshcore-cli>=0.0.0"],
99
"iot_class": "local_polling",
10-
"version": "2.1.4",
10+
"version": "2.1.5",
1111
"config_flow": true
1212
}

custom_components/meshcore/sensor.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
DOMAIN,
2929
ENTITY_DOMAIN_SENSOR,
3030
CONF_REPEATER_SUBSCRIPTIONS,
31+
CONF_TRACKED_CLIENTS,
3132
NodeType,
3233
)
3334
from .utils import get_node_type_str
@@ -44,6 +45,23 @@
4445
UTILIZATION_SUFFIX = "_utilization"
4546
RATE_SUFFIX = "_rate"
4647

48+
# Path tracking sensors for repeaters and clients
49+
PATH_SENSORS = [
50+
SensorEntityDescription(
51+
key="out_path",
52+
name="Routing Path",
53+
icon="mdi:map-marker-path",
54+
native_unit_of_measurement=None,
55+
),
56+
SensorEntityDescription(
57+
key="out_path_len",
58+
name="Path Length",
59+
icon="mdi:counter",
60+
native_unit_of_measurement="hops",
61+
state_class=SensorStateClass.MEASUREMENT,
62+
),
63+
]
64+
4765

4866
# Define sensors for the main device
4967
SENSORS = [
@@ -437,6 +455,38 @@ async def async_setup_entry(
437455
entities.append(sensor)
438456
except Exception as ex:
439457
_LOGGER.error(f"Error creating repeater sensor {description.key}: {ex}")
458+
459+
# Add path tracking sensors for this repeater
460+
for path_description in PATH_SENSORS:
461+
try:
462+
sensor = MeshCorePathSensor(
463+
coordinator,
464+
path_description,
465+
repeater,
466+
"repeater"
467+
)
468+
entities.append(sensor)
469+
except Exception as ex:
470+
_LOGGER.error(f"Error creating path sensor {path_description.key} for repeater: {ex}")
471+
472+
# Add path sensors for tracked clients
473+
client_subscriptions = entry.data.get(CONF_TRACKED_CLIENTS, [])
474+
if client_subscriptions:
475+
for client in client_subscriptions:
476+
_LOGGER.info(f"Creating path sensors for client: {client.get('name')} ({client.get('pubkey_prefix')})")
477+
478+
# Add path tracking sensors for this client
479+
for path_description in PATH_SENSORS:
480+
try:
481+
sensor = MeshCorePathSensor(
482+
coordinator,
483+
path_description,
484+
client,
485+
"client"
486+
)
487+
entities.append(sensor)
488+
except Exception as ex:
489+
_LOGGER.error(f"Error creating path sensor {path_description.key} for client: {ex}")
440490

441491
async_add_entities(entities)
442492

@@ -576,6 +626,101 @@ def device_info(self):
576626
def native_value(self) -> Any:
577627
return self._native_value
578628

629+
class MeshCorePathSensor(CoordinatorEntity, SensorEntity):
630+
"""Sensor for tracking node routing path with CONTACTS event updates."""
631+
632+
def __init__(
633+
self,
634+
coordinator: DataUpdateCoordinator,
635+
description: SensorEntityDescription,
636+
node_config: dict,
637+
node_type: str,
638+
) -> None:
639+
"""Initialize the path tracking sensor."""
640+
super().__init__(coordinator)
641+
self.entity_description = description
642+
self.node_name = node_config.get("name", "Unknown")
643+
self.node_type = node_type
644+
645+
# Use the provided pubkey_prefix
646+
self.pubkey_prefix = node_config.get("pubkey_prefix", "")
647+
self.public_key_short = self.pubkey_prefix[:6] if self.pubkey_prefix else ""
648+
649+
# Generate a unique device_id for this node using pubkey_prefix
650+
self.device_id = f"{coordinator.config_entry.entry_id}_{node_type}_{self.pubkey_prefix}"
651+
652+
# Set friendly name
653+
self._attr_name = description.name
654+
655+
# Build device name with pubkey
656+
device_name = f"MeshCore {node_type.title()}: {self.node_name} ({self.public_key_short})"
657+
658+
# Set unique ID
659+
self._attr_unique_id = f"{self.device_id}_{description.key}_{self.public_key_short}_{self.node_name}"
660+
661+
# Set entity ID
662+
self.entity_id = format_entity_id(
663+
ENTITY_DOMAIN_SENSOR,
664+
self.pubkey_prefix[:10],
665+
description.key,
666+
self.node_name
667+
)
668+
669+
# Set device info to create a separate device for this node
670+
device_info = {
671+
"identifiers": {(DOMAIN, self.device_id)},
672+
"name": device_name,
673+
"manufacturer": "MeshCore",
674+
"model": f"Mesh {node_type.title()}",
675+
"via_device": (DOMAIN, coordinator.config_entry.entry_id), # Link to the main device
676+
}
677+
678+
self._attr_device_info = DeviceInfo(**device_info)
679+
self._native_value = None
680+
681+
async def async_added_to_hass(self):
682+
"""Register event handlers when entity is added to hass."""
683+
await super().async_added_to_hass()
684+
685+
# Only set up listener if MeshCore instance is available
686+
if not self.coordinator.api.mesh_core:
687+
_LOGGER.warning(f"No MeshCore instance available for path tracking: {self.node_name}")
688+
return
689+
690+
# Only set up if we have a pubkey_prefix
691+
if not self.pubkey_prefix:
692+
_LOGGER.warning(f"No pubkey_prefix available for node {self.node_name}, can't track path")
693+
return
694+
695+
meshcore = self.coordinator.api.mesh_core
696+
697+
def handle_contacts_event(event: Event):
698+
"""Handle CONTACTS event to update path information."""
699+
if event.type != EventType.CONTACTS:
700+
return
701+
702+
# Find our contact using the helper method
703+
contact = meshcore.get_contact_by_key_prefix(self.pubkey_prefix)
704+
if contact:
705+
# Update the sensor based on the description key
706+
if self.entity_description.key == "out_path":
707+
self._native_value = contact.get("out_path", "")
708+
elif self.entity_description.key == "out_path_len":
709+
path_len = contact.get("out_path_len", -1)
710+
self._native_value = path_len if path_len != -1 else None
711+
712+
# Subscribe to CONTACTS events
713+
meshcore.dispatcher.subscribe(
714+
EventType.CONTACTS,
715+
handle_contacts_event
716+
)
717+
718+
_LOGGER.debug(f"Set up path tracking for {self.node_type} {self.node_name} ({self.pubkey_prefix})")
719+
720+
@property
721+
def native_value(self) -> Any:
722+
return self._native_value
723+
579724
class MeshCoreRepeaterSensor(CoordinatorEntity, SensorEntity):
580725
"""Sensor for repeater statistics with event-based updates."""
581726

custom_components/meshcore/telemetry_sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ async def _handle_telemetry_event(self, event: Event):
159159
_LOGGER.debug("No LPP data in telemetry event")
160160
return
161161

162-
pubkey_prefix = event.payload.get("pubkey_pre", "")
162+
pubkey_prefix = event.payload.get("pubkey_prefix", "")
163163
lpp_data = event.payload.get("lpp", [])
164164

165165
# If no pubkey_prefix, this might be self telemetry

0 commit comments

Comments
 (0)