Files
pyMC_Repeater/repeater/data_acquisition/storage_collector.py
T
Thibault VINCENT 8008f09634 feat(ws): carry the sidebar vitals in the stats broadcast, decimate the 24h aggregate
The 5s WebSocket broadcast used to carry uptime_seconds alone — while
the dashboard sidebar starved: its duty-cycle gauge, noise floor and
mode never travel over WS, and the heartbeat marks stats fresh, which
suppresses the UI's HTTP fallback poll. The vitals now ride every
beat, all cheap in-memory scalars (~200 bytes): utilization_percent,
noise_floor_dbm (kept on record, no per-beat SQL), the repeater mode
(so a change made over RF or CLI reaches the web badge), and the
advert tier counters the sidebar's OK/Drop line shows, injected as a
callback from the daemon like the other collector hooks.

In exchange the heavy get_packet_stats(24h) SQL aggregate is decimated
to one beat in six (30s): nobody reads a 24h cumulative at 5s
resolution, and the UI already treats both payload fields as optional,
so older dashboards keep working. Net wire traffic goes down.

Signed-off-by: Thibault VINCENT <root@devcat.org>
2026-08-22 01:28:50 +02:00

717 lines
28 KiB
Python

import asyncio
import concurrent.futures
import logging
import threading
import time
from typing import Any, Dict, Optional
from repeater.config import resolve_storage_dir
from .mqtt_handler import MeshCoreToMqttPusher
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
logger = logging.getLogger("StorageCollector")
class StorageCollector:
def __init__(self, config: dict, local_identity=None, repeater_handler=None):
self.config = config
self.repeater_handler = repeater_handler
self.glass_publish_callback = None
self._pending_tasks = set()
metrics_config = config.get("metrics")
if not isinstance(metrics_config, dict):
metrics_config = {}
self.rrd_enabled = bool(metrics_config.get("rrd_enabled", True))
# Dedicated single writer thread for all blocking storage work (the SQLite
# write, the cumulative-counts aggregate, RRD updates, and network
# publishing). This keeps that work off the asyncio event loop, which it
# was previously stalling for seconds per packet on a busy mesh — starving
# every other coroutine (e.g. send_advert would time out). One worker
# preserves packet write ordering and reuses a single thread-local SQLite
# connection (no WAL writer contention, no connection fan-out).
self._db_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="storage-writer"
)
self.storage_dir = resolve_storage_dir(config)
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = None
if self.rrd_enabled:
candidate_rrd_handler = RRDToolHandler(self.storage_dir)
if candidate_rrd_handler.available and candidate_rrd_handler.rrd_path.exists():
self.rrd_handler = candidate_rrd_handler
logger.info("RRDtool metrics enabled")
else:
logger.warning("RRDtool requested but unavailable; using SQLite metrics fallback")
else:
logger.info("RRDtool metrics disabled; SQLite metrics fallback will be used")
# Initialize MQTT handler only when at least one broker is configured
self.mqtt_handler = None
mqtt_brokers_config = config.get("mqtt_brokers", {}) or {}
letsmesh_config = config.get("letsmesh", {}) or {}
mqtt_config = config.get("mqtt", {}) or {}
has_brokers_configured = (
bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config)
)
if has_brokers_configured and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
self.mqtt_handler = MeshCoreToMqttPusher(
local_identity=local_identity,
config=config,
stats_provider=self._get_live_stats,
)
self.mqtt_handler.connect()
public_key_hex = local_identity.get_public_key().hex()
logger.info(f"MQTT handler initialized with public key: {public_key_hex[:16]}...")
except Exception as e:
logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None
else:
logger.info("MQTT handler disabled - no brokers configured")
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
self.hardware_stats = HardwareStatsCollector()
logger.info("Hardware stats collector initialized")
# Initialize WebSocket handler for real-time updates
self.websocket_available = False
self.websocket_has_connected_clients = lambda: False
# Wired by the daemon once the advert helper exists; returns the
# rate-limit stats dict the sidebar's advert tier reads.
self.advert_stats_getter = None
self._last_noise_floor_dbm: Optional[float] = None
self._stats_broadcast_seq = 0
self._ws_stats_broadcast_interval_sec: float = 5.0
self._stats_stop_event = threading.Event()
self._stats_thread: Optional[threading.Thread] = None
try:
from .websocket_handler import (
broadcast_packet,
broadcast_stats,
has_connected_clients,
)
self.websocket_broadcast_packet = broadcast_packet
self.websocket_broadcast_stats = broadcast_stats
self.websocket_has_connected_clients = has_connected_clients
self.websocket_available = True
logger.info("WebSocket handler initialized for real-time updates")
# Broadcast aggregate stats on a fixed cadence rather than inline on the
# per-packet write path. get_packet_stats(24h) is a multi-second aggregate;
# running it inside _record_packet_blocking made the storage writer thread
# spend ~1-2s of every 5s on it, competing with packet inserts. A dedicated
# tick keeps the writer doing only fast writes and only runs the aggregate
# when a dashboard client is actually connected.
self._stats_thread = threading.Thread(
target=self._stats_broadcast_loop,
name="stats-broadcast",
daemon=True,
)
self._stats_thread.start()
except ImportError:
logger.debug("WebSocket handler not available")
def _track_task(self, task: asyncio.Task):
"""Track background task for lifecycle management and error handling."""
self._pending_tasks.add(task)
def on_done(t: asyncio.Task):
self._pending_tasks.discard(t)
try:
t.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Background task error: {e}", exc_info=True)
task.add_done_callback(on_done)
def _schedule_background(self, coro_factory, *args, sync_fallback=None):
"""Schedule a coroutine if a loop exists; otherwise run sync fallback."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
if sync_fallback is not None:
sync_fallback(*args)
return
task = loop.create_task(coro_factory(*args))
self._track_task(task)
def _get_live_stats(self) -> dict:
"""Get live stats from RepeaterHandler"""
if not self.repeater_handler:
return {
"uptime_secs": 0,
"packets_sent": 0,
"packets_received": 0,
"errors": 0,
"queue_len": 0,
}
uptime_secs = int(time.time() - self.repeater_handler.start_time)
# Get airtime stats
airtime_stats = self.repeater_handler.airtime_mgr.get_stats()
# Get latest noise floor from database
noise_floor = None
try:
recent_noise = self.sqlite_handler.get_noise_floor_history(hours=0.5, limit=1)
if recent_noise and len(recent_noise) > 0:
noise_floor = recent_noise[-1].get("noise_floor_dbm")
except Exception as e:
logger.debug(f"Could not fetch noise floor: {e}")
stats = {
"uptime_secs": uptime_secs,
"packets_sent": self.repeater_handler.forwarded_count,
"packets_received": self.repeater_handler.rx_count,
"errors": 0,
"queue_len": 0, # N/A for Python repeater
}
# Add airtime stats
if airtime_stats:
stats["tx_air_secs"] = airtime_stats["total_airtime_ms"] / 1000
stats["current_airtime_ms"] = airtime_stats["current_airtime_ms"]
stats["utilization_percent"] = airtime_stats["utilization_percent"]
# Add noise floor if available
if noise_floor is not None:
stats["noise_floor"] = noise_floor
return stats
def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
"""Record a packet to storage and publish it.
All blocking work — the SQLite write, the cumulative-counts aggregate, the
RRD update, and network publishing — runs on the dedicated writer thread so
it never blocks the asyncio event loop. Callers treat this as
fire-and-forget (the previous synchronous version blocked the loop).
Args:
packet_record: Dictionary containing packet information
skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt
"""
logger.debug(
f"Recording packet: type={packet_record.get('type')}, "
f"transmitted={packet_record.get('transmitted')}"
)
self._submit_db(self._record_packet_blocking, packet_record, skip_mqtt_if_invalid)
def _submit_db(self, fn, *args):
"""Run a blocking storage operation on the dedicated writer thread.
Falls back to running inline only if the executor has already been shut
down (process teardown), so late records are not silently dropped.
"""
try:
self._db_executor.submit(self._run_db_task, fn, *args)
except RuntimeError:
self._run_db_task(fn, *args)
def _run_db_task(self, fn, *args):
"""Execute a writer-thread task, logging (not raising) on failure."""
try:
fn(*args)
except Exception as e:
logger.error(f"Storage writer task failed: {e}", exc_info=True)
def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool):
"""Store, aggregate, update metrics, and publish one packet (writer thread)."""
packet_id = self.sqlite_handler.store_packet(packet_record)
if packet_id is not None:
packet_record["id"] = packet_id
if self.rrd_handler is not None:
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self._publish_packet_sync(packet_record, skip_mqtt)
def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool):
"""Publish a single packet (glass, per-packet WebSocket event, MQTT).
Only fast, per-packet work runs here. The aggregate stats broadcast is
driven separately by _stats_broadcast_loop so the writer thread is not
held by the multi-second get_packet_stats(24h) query.
"""
self._publish_to_glass(packet_record, "packet")
if self.websocket_available:
try:
self.websocket_broadcast_packet(packet_record)
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
self._publish_packet_to_mqtt(packet_record)
# The heavy 24h SQL aggregate rides one beat in six (30s at the 5s
# default): nobody reads a 24h cumulative at 5s resolution, and the
# sidebar vitals it used to travel with are cheap in-memory scalars.
PACKET_STATS_EVERY_N_BEATS = 6
def _broadcast_stats_once(self) -> None:
"""Broadcast the sidebar vitals; the 24h aggregate is decimated.
The beat carries scalars only — a few hundred bytes — so the
dashboard sidebar can read every vital live on any page. Series
(noise-floor history, charts) stay on their own slow HTTP paths.
"""
system_stats: Dict[str, Any] = {
"uptime_seconds": (
time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
),
"mode": self.config.get("repeater", {}).get("mode", "forward"),
}
airtime_mgr = getattr(self.repeater_handler, "airtime_mgr", None)
if airtime_mgr is not None:
airtime_stats = airtime_mgr.get_stats()
if airtime_stats:
system_stats["utilization_percent"] = airtime_stats["utilization_percent"]
if self._last_noise_floor_dbm is not None:
system_stats["noise_floor_dbm"] = self._last_noise_floor_dbm
if self.advert_stats_getter is not None:
try:
tier = self.advert_stats_getter()
system_stats["advert_tier"] = {
"current_tier": tier.get("adaptive", {}).get("current_tier"),
"adverts_allowed": tier.get("stats", {}).get("adverts_allowed", 0),
"adverts_dropped": tier.get("stats", {}).get("adverts_dropped", 0),
"active_penalties": len(tier.get("active_penalties") or {}),
}
except Exception as e:
logger.debug(f"Advert stats unavailable for broadcast: {e}")
payload: Dict[str, Any] = {"system_stats": system_stats}
if self._stats_broadcast_seq % self.PACKET_STATS_EVERY_N_BEATS == 0:
payload["packet_stats"] = self.sqlite_handler.get_packet_stats(hours=24)
self._stats_broadcast_seq += 1
self.websocket_broadcast_stats(payload)
def _stats_broadcast_loop(self) -> None:
"""Broadcast aggregate stats every interval while clients are connected.
Runs on its own thread (off the event loop and off the storage writer) so
the heavy get_packet_stats(24h) aggregate never sits in the packet write
path. Skips the query entirely when no dashboard client is connected.
"""
while not self._stats_stop_event.wait(self._ws_stats_broadcast_interval_sec):
try:
if self.websocket_has_connected_clients():
self._broadcast_stats_once()
except Exception as e:
logger.debug(f"Stats broadcast failed: {e}")
def _publish_packet_to_mqtt(self, packet_record: dict):
"""Publish packet to mqtt broker if enabled and allowed.
The ``duration`` field in the published JSON is sourced from
``packet_record['airtime_ms']``, populated upstream by
RepeaterHandler._build_packet_record using the Semtech reference
time-on-air formula. No recomputation is needed here.
"""
if not self.mqtt_handler:
return
try:
packet_type = packet_record.get("type")
if packet_type is None:
logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
return
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
packet = PacketRecord.from_packet_record(
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
)
if packet:
self.mqtt_handler.publish_packet(packet.to_dict())
logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt")
else:
logger.debug("Skipped mqtt publish: packet missing raw_packet data")
except Exception as e:
logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True)
def record_advert(self, advert_record: dict):
"""Record advert to storage and defer network publishing to background tasks."""
self.sqlite_handler.store_advert(advert_record)
self._schedule_background(
self._deferred_publish_advert,
advert_record,
sync_fallback=self._publish_advert_sync,
)
async def _deferred_publish_advert(self, advert_record: dict):
"""Deferred background task for advert publishing."""
try:
self._publish_advert_sync(advert_record)
except Exception as e:
logger.error(f"Deferred advert publish failed: {e}", exc_info=True)
def _publish_advert_sync(self, advert_record: dict):
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(advert_record, "advert")
self._publish_to_glass(advert_record, "advert")
def record_noise_floor(self, noise_floor_dbm: float):
"""Record noise floor to storage and defer network publishing to background tasks."""
self._last_noise_floor_dbm = noise_floor_dbm
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
self.sqlite_handler.store_noise_floor(noise_record)
self._schedule_background(
self._deferred_publish_noise_floor,
noise_record,
sync_fallback=self._publish_noise_floor_sync,
)
async def _deferred_publish_noise_floor(self, noise_record: dict):
"""Deferred background task for noise floor publishing."""
try:
self._publish_noise_floor_sync(noise_record)
except Exception as e:
logger.error(f"Deferred noise floor publish failed: {e}", exc_info=True)
def _publish_noise_floor_sync(self, noise_record: dict):
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(noise_record, "noise_floor")
self._publish_to_glass(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll and defer publishing."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
self._schedule_background(
self._deferred_publish_crc_errors,
crc_record,
sync_fallback=self._publish_crc_errors_sync,
)
async def _deferred_publish_crc_errors(self, crc_record: dict):
"""Deferred background task for CRC error publishing."""
try:
self._publish_crc_errors_sync(crc_record)
except Exception as e:
logger.error(f"Deferred CRC errors publish failed: {e}", exc_info=True)
def _publish_crc_errors_sync(self, crc_record: dict):
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(crc_record, "crc_errors")
self._publish_to_glass(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list:
return self.sqlite_handler.get_crc_error_history(hours, limit)
def get_policy_event_counts(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 60,
) -> list:
return self.sqlite_handler.get_policy_event_counts(
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
bucket_seconds=bucket_seconds,
)
def get_lbt_diagnostics(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 300,
severe_attempt_threshold: int = 4,
) -> dict:
return self.sqlite_handler.get_lbt_diagnostics(
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
bucket_seconds=bucket_seconds,
severe_attempt_threshold=severe_attempt_threshold,
)
def get_packet_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_packet_stats(hours)
def get_recent_packets(self, limit: int = 100) -> list:
return self.sqlite_handler.get_recent_packets(limit)
def get_filtered_packets(
self,
packet_type: Optional[int] = None,
route: Optional[int] = None,
start_timestamp: Optional[float] = None,
end_timestamp: Optional[float] = None,
limit: int = 1000,
offset: int = 0,
) -> list:
return self.sqlite_handler.get_filtered_packets(
packet_type, route, start_timestamp, end_timestamp, limit, offset
)
def get_airtime_data(
self,
start_timestamp: Optional[float] = None,
end_timestamp: Optional[float] = None,
limit: int = 50000,
) -> list:
return self.sqlite_handler.get_airtime_data(start_timestamp, end_timestamp, limit)
def get_airtime_buckets(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 60,
sf: int = 9,
bw_hz: int = 62500,
cr: int = 5,
preamble: int = 17,
) -> dict:
return self.sqlite_handler.get_airtime_buckets(
start_timestamp, end_timestamp, bucket_seconds, sf, bw_hz, cr, preamble
)
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_hash(packet_hash)
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_id(packet_id)
def get_neighbor_link_history(
self,
*,
peer_hash: str,
path_hash_size: int,
hours: int = 24,
limit: int = 1000,
) -> list:
return self.sqlite_handler.get_neighbor_link_history(
peer_hash=peer_hash,
path_hash_size=path_hash_size,
hours=hours,
limit=limit,
)
def get_rrd_data(
self,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
resolution: str = "average",
) -> Optional[dict]:
return self.get_metrics_data(start_time, end_time, resolution)
def get_metrics_data(
self,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
resolution: str = "average",
) -> dict:
if self.rrd_handler is not None:
try:
rrd_data = self.rrd_handler.get_data(start_time, end_time, resolution)
except Exception as e:
logger.warning(
f"RRDtool metrics read failed; using SQLite metrics fallback: {e}",
exc_info=True,
)
else:
if self._metrics_data_is_valid(rrd_data):
rrd_data.setdefault("data_source", "rrd")
return rrd_data
logger.warning(
"RRDtool metrics read returned no usable data; using SQLite metrics fallback"
)
sqlite_data = self.sqlite_handler.get_metrics_data(start_time, end_time, resolution)
sqlite_data.setdefault("data_source", "sqlite")
return sqlite_data
def _metrics_data_is_valid(self, metrics_data: Optional[dict]) -> bool:
if not isinstance(metrics_data, dict):
return False
if not isinstance(metrics_data.get("metrics"), dict):
return False
if not isinstance(metrics_data.get("timestamps"), list):
return False
return True
def get_packet_type_stats(self, hours: int = 24) -> dict:
if self.rrd_handler is not None:
try:
rrd_stats = self.rrd_handler.get_packet_type_stats(hours)
except Exception as e:
logger.warning(
f"RRDtool packet type stats failed; using SQLite fallback: {e}",
exc_info=True,
)
else:
if rrd_stats:
return rrd_stats
logger.warning("Falling back to SQLite for packet type stats")
return self.sqlite_handler.get_packet_type_stats(hours)
def get_route_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_route_stats(hours)
def get_neighbors(self) -> dict:
return self.sqlite_handler.get_neighbors()
def get_neighbor_scopes(self) -> dict:
return self.sqlite_handler.get_neighbor_scopes()
def record_neighbor_scope(
self,
pubkey: str,
status: str,
scopes: Optional[str] = None,
queried_at: Optional[float] = None,
) -> bool:
return self.sqlite_handler.record_neighbor_scope(pubkey, status, scopes, queried_at)
def get_daemon_state(self, key: str) -> Optional[dict]:
return self.sqlite_handler.get_daemon_state(key)
def set_daemon_state(self, key: str, value: dict) -> bool:
return self.sqlite_handler.set_daemon_state(key, value)
def get_node_name_by_pubkey(self, pubkey: str) -> Optional[str]:
"""
Lookup node name from adverts table by public key.
Args:
pubkey: Public key in hex string format
Returns:
Node name if found, None otherwise
"""
try:
import sqlite3
with sqlite3.connect(self.sqlite_handler.sqlite_path) as conn:
result = conn.execute(
"SELECT node_name FROM adverts WHERE pubkey = ? AND node_name IS NOT NULL ORDER BY last_seen DESC LIMIT 1",
(pubkey,),
).fetchone()
return result[0] if result else None
except Exception as e:
logger.debug(f"Could not lookup node name for {pubkey[:8] if pubkey else 'None'}: {e}")
return None
def cleanup_old_data(self, days: int = 7, companion_events_days: Optional[int] = None):
self.sqlite_handler.cleanup_old_data(days, companion_events_days=companion_events_days)
def get_noise_floor_history(self, hours: int = 24, limit: int = None, offset: int = 0) -> list:
return self.sqlite_handler.get_noise_floor_history(hours, limit, offset)
def get_noise_floor_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_noise_floor_stats(hours)
def close(self):
# Stop the stats broadcast thread.
self._stats_stop_event.set()
if self._stats_thread is not None:
self._stats_thread.join(timeout=2)
# Drain and stop the storage writer thread first so pending writes and
# publishes complete before MQTT and the DB connections are torn down.
self._db_executor.shutdown(wait=True)
# Cancel all pending background tasks
for task in self._pending_tasks:
if not task.done():
task.cancel()
if self.mqtt_handler:
try:
self.mqtt_handler.disconnect()
logger.info("MQTT handler disconnected")
except Exception as e:
logger.error(f"Error disconnecting MQTT handler: {e}")
def set_glass_publisher(self, publish_callback):
self.glass_publish_callback = publish_callback
def _publish_to_glass(self, record: dict, record_type: str):
if not self.glass_publish_callback:
return
try:
self.glass_publish_callback(record_type, record)
except Exception as e:
logger.debug(f"Failed to publish telemetry to Glass MQTT: {e}")
def create_transport_key(
self,
name: str,
flood_policy: str,
transport_key: Optional[str] = None,
parent_id: Optional[int] = None,
last_used: Optional[float] = None,
) -> Optional[int]:
return self.sqlite_handler.create_transport_key(
name, flood_policy, transport_key, parent_id, last_used
)
def get_transport_keys(self) -> list:
return self.sqlite_handler.get_transport_keys()
def get_transport_key_by_id(self, key_id: int) -> Optional[dict]:
return self.sqlite_handler.get_transport_key_by_id(key_id)
def update_transport_key(
self,
key_id: int,
name: Optional[str] = None,
flood_policy: Optional[str] = None,
transport_key: Optional[str] = None,
parent_id: Optional[int] = None,
last_used: Optional[float] = None,
) -> bool:
return self.sqlite_handler.update_transport_key(
key_id, name, flood_policy, transport_key, parent_id, last_used
)
def delete_transport_key(self, key_id: int) -> bool:
return self.sqlite_handler.delete_transport_key(key_id)
def delete_advert(self, advert_id: int) -> bool:
return self.sqlite_handler.delete_advert(advert_id)
def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: str | None) -> int:
return self.sqlite_handler.delete_neighbors_by_pubkey_prefix(pubkey_prefix)
def get_hardware_stats(self) -> Optional[dict]:
"""Get current hardware statistics"""
try:
return self.hardware_stats.get_stats()
except Exception as e:
logger.error(f"Error getting hardware stats: {e}")
return None
def get_hardware_processes(self) -> Optional[list]:
"""Get current process summary"""
try:
return self.hardware_stats.get_processes_summary()
except Exception as e:
logger.error(f"Error getting hardware processes: {e}")
return None