Actu8HubClient 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- actu8hubclient-1.1.0/Actu8HubClient/Actu8HubClient.py +1149 -0
- actu8hubclient-1.1.0/Actu8HubClient/README.md +336 -0
- actu8hubclient-1.1.0/Actu8HubClient/__init__.py +11 -0
- actu8hubclient-1.1.0/Actu8HubClient.egg-info/PKG-INFO +366 -0
- actu8hubclient-1.1.0/Actu8HubClient.egg-info/SOURCES.txt +10 -0
- actu8hubclient-1.1.0/Actu8HubClient.egg-info/dependency_links.txt +1 -0
- actu8hubclient-1.1.0/Actu8HubClient.egg-info/requires.txt +7 -0
- actu8hubclient-1.1.0/Actu8HubClient.egg-info/top_level.txt +1 -0
- actu8hubclient-1.1.0/PKG-INFO +366 -0
- actu8hubclient-1.1.0/README.md +22 -0
- actu8hubclient-1.1.0/pyproject.toml +45 -0
- actu8hubclient-1.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Actu8Hub Python Client Library
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
A lightweight, non-blocking client for connecting Python-based devices
|
|
6
|
+
(Raspberry Pi, Jetson Nano, Linux SBCs, etc.) to the Actu8Hub IoT platform.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Automatic HTTP polling or WebSocket real-time connection
|
|
10
|
+
- Sensor reading queue with batch transmission
|
|
11
|
+
- Text/string sensor value support
|
|
12
|
+
- Actuator command handling with smart confirmation
|
|
13
|
+
- Device logging with levels (debug, info, warn, error)
|
|
14
|
+
- Offline ring buffer (queues readings when connection is lost)
|
|
15
|
+
- File upload helper (2-step signed URL process)
|
|
16
|
+
- Non-blocking background sync via threading
|
|
17
|
+
- Rate limit aware: 60 requests/minute per device
|
|
18
|
+
|
|
19
|
+
Usage (HTTP polling):
|
|
20
|
+
from Actu8HubClient import Actu8HubClient
|
|
21
|
+
|
|
22
|
+
hub = Actu8HubClient("a8h_YOUR_API_KEY")
|
|
23
|
+
hub.queue_reading("Temperature", 24.5)
|
|
24
|
+
hub.queue_text_reading("Status", "Normal")
|
|
25
|
+
hub.sync()
|
|
26
|
+
|
|
27
|
+
Usage (WebSocket real-time):
|
|
28
|
+
from Actu8HubClient import Actu8HubClient
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
hub = Actu8HubClient("a8h_YOUR_API_KEY")
|
|
32
|
+
|
|
33
|
+
def on_actuator(cmd):
|
|
34
|
+
print(f"Actuator {cmd.name}: active={cmd.is_active}, value={cmd.value}")
|
|
35
|
+
return 1 # 1=changed, 0=already matched, -1=error
|
|
36
|
+
|
|
37
|
+
hub.on_actuator(on_actuator)
|
|
38
|
+
hub.connect_websocket()
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
hub.queue_reading("Temperature", 24.5)
|
|
42
|
+
hub.queue_text_reading("Status", "Normal")
|
|
43
|
+
hub.transmit_readings()
|
|
44
|
+
hub.transmit_text_readings()
|
|
45
|
+
time.sleep(5)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import mimetypes
|
|
53
|
+
import os
|
|
54
|
+
import ssl
|
|
55
|
+
import threading
|
|
56
|
+
import time
|
|
57
|
+
from collections import deque
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
from enum import Enum
|
|
60
|
+
from typing import Any, Callable, Deque, Dict, List, Optional
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
import requests
|
|
64
|
+
except ImportError:
|
|
65
|
+
requests = None # type: ignore[assignment]
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
import websocket as _ws_mod # websocket-client library
|
|
69
|
+
except ImportError:
|
|
70
|
+
_ws_mod = None # type: ignore[assignment]
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger("actu8hub")
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Constants
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_DEFAULT_BASE_URL = "https://api.actu8hub.com/functions/v1"
|
|
79
|
+
_DEFAULT_WS_URL = "wss://api.actu8hub.com/functions/v1"
|
|
80
|
+
_RING_BUFFER_SIZE = 50
|
|
81
|
+
_HEARTBEAT_INTERVAL_S = 15
|
|
82
|
+
_RECONNECT_INTERVAL_S = 5
|
|
83
|
+
_UPLOAD_URL_EXPIRY_S = 300
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Data Structures
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
class LogLevel(Enum):
|
|
91
|
+
"""Log severity levels matching the Actu8Hub API."""
|
|
92
|
+
DEBUG = "debug"
|
|
93
|
+
INFO = "info"
|
|
94
|
+
WARN = "warn"
|
|
95
|
+
ERROR = "error"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ActuatorCommand:
|
|
100
|
+
"""Actuator command received from the server."""
|
|
101
|
+
name: str
|
|
102
|
+
is_active: bool
|
|
103
|
+
value: float
|
|
104
|
+
command_text: str = "" # Optional text payload for Command-type actuators
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class SensorReading:
|
|
109
|
+
"""Numeric sensor reading to be sent to the server."""
|
|
110
|
+
name: str
|
|
111
|
+
value: float
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class TextReading:
|
|
116
|
+
"""Text/string sensor reading to be sent to the server."""
|
|
117
|
+
name: str
|
|
118
|
+
text_value: str
|
|
119
|
+
media_url: Optional[str] = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class LogEntry:
|
|
124
|
+
"""Log entry to be sent to the server."""
|
|
125
|
+
message: str
|
|
126
|
+
level: LogLevel
|
|
127
|
+
timestamp_ms: float # time.time() * 1000 when created
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class _PendingConfirmation:
|
|
132
|
+
"""Pending actuator confirmation waiting to be sent."""
|
|
133
|
+
name: str
|
|
134
|
+
is_active: bool
|
|
135
|
+
value: float
|
|
136
|
+
status: str = "ok" # "ok" or "mismatch"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Type alias for the actuator callback.
|
|
140
|
+
# Return: 1 = state changed, 0 = already matched, -1 = error
|
|
141
|
+
ActuatorCallback = Callable[[ActuatorCommand], int]
|
|
142
|
+
ConnectionCallback = Callable[[bool], None]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Ring Buffer for offline resilience
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
class _RingBuffer:
|
|
150
|
+
"""Fixed-size deque that silently drops oldest items when full."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, maxsize: int = _RING_BUFFER_SIZE) -> None:
|
|
153
|
+
self._buf: Deque[Any] = deque(maxlen=maxsize)
|
|
154
|
+
self._lock = threading.Lock()
|
|
155
|
+
|
|
156
|
+
def append(self, item: Any) -> None:
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._buf.append(item)
|
|
159
|
+
|
|
160
|
+
def drain(self) -> List[Any]:
|
|
161
|
+
"""Return all items and clear the buffer atomically."""
|
|
162
|
+
with self._lock:
|
|
163
|
+
items = list(self._buf)
|
|
164
|
+
self._buf.clear()
|
|
165
|
+
return items
|
|
166
|
+
|
|
167
|
+
def __len__(self) -> int:
|
|
168
|
+
with self._lock:
|
|
169
|
+
return len(self._buf)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def empty(self) -> bool:
|
|
173
|
+
return len(self) == 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Main Client Class
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
class Actu8HubClient:
|
|
181
|
+
"""
|
|
182
|
+
Actu8Hub IoT platform client for Python devices.
|
|
183
|
+
|
|
184
|
+
Supports HTTP polling (battery-friendly) and WebSocket (real-time)
|
|
185
|
+
connection modes. All network I/O is non-blocking — sync() and
|
|
186
|
+
transmit methods will not freeze your main control loop.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
api_key: Device API key from the Actu8Hub dashboard (``a8h_`` or ``esp32_`` prefix).
|
|
190
|
+
base_url: Override the default Supabase functions URL for self-hosted instances.
|
|
191
|
+
ws_url: Override the WebSocket URL (defaults to follow base_url).
|
|
192
|
+
ring_buffer_size: Max queued readings kept in RAM when offline (default 50).
|
|
193
|
+
max_log_queue: Maximum number of log entries to buffer (default 50).
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
api_key: str,
|
|
199
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
200
|
+
ws_url: Optional[str] = None,
|
|
201
|
+
ring_buffer_size: int = _RING_BUFFER_SIZE,
|
|
202
|
+
max_log_queue: int = 50,
|
|
203
|
+
) -> None:
|
|
204
|
+
if not api_key:
|
|
205
|
+
raise ValueError("api_key is required")
|
|
206
|
+
|
|
207
|
+
self._api_key = api_key
|
|
208
|
+
self._base_url = base_url.rstrip("/")
|
|
209
|
+
if ws_url is None:
|
|
210
|
+
# Follow base_url so one override covers HTTP and WebSocket
|
|
211
|
+
ws_url = (
|
|
212
|
+
_DEFAULT_WS_URL
|
|
213
|
+
if base_url.rstrip("/") == _DEFAULT_BASE_URL
|
|
214
|
+
else "wss://" + base_url.split("://", 1)[-1]
|
|
215
|
+
)
|
|
216
|
+
self._ws_url = ws_url.rstrip("/")
|
|
217
|
+
|
|
218
|
+
# Queues (thread-safe ring buffers)
|
|
219
|
+
self._readings = _RingBuffer(ring_buffer_size)
|
|
220
|
+
self._text_readings = _RingBuffer(ring_buffer_size)
|
|
221
|
+
self._logs = _RingBuffer(max_log_queue)
|
|
222
|
+
self._confirmations: List[_PendingConfirmation] = []
|
|
223
|
+
self._confirm_lock = threading.Lock()
|
|
224
|
+
|
|
225
|
+
# Callbacks
|
|
226
|
+
self._actuator_cb: Optional[ActuatorCallback] = None
|
|
227
|
+
self._connection_cb: Optional[ConnectionCallback] = None
|
|
228
|
+
|
|
229
|
+
# WebSocket state
|
|
230
|
+
self._ws: Optional[Any] = None
|
|
231
|
+
self._ws_connected = False
|
|
232
|
+
self._ws_enabled = False
|
|
233
|
+
self._ws_thread: Optional[threading.Thread] = None
|
|
234
|
+
self._ws_stop_event = threading.Event()
|
|
235
|
+
self._last_activity = 0.0
|
|
236
|
+
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
237
|
+
self._heartbeat_stop_event = threading.Event()
|
|
238
|
+
|
|
239
|
+
# Background sync state
|
|
240
|
+
self._bg_thread: Optional[threading.Thread] = None
|
|
241
|
+
self._bg_stop_event = threading.Event()
|
|
242
|
+
|
|
243
|
+
# HTTP session (reuse connections)
|
|
244
|
+
self._session: Optional[Any] = None
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Properties
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def is_connected(self) -> bool:
|
|
252
|
+
"""Check if currently connected (WebSocket mode) or session is open (HTTP mode)."""
|
|
253
|
+
if self._ws_enabled:
|
|
254
|
+
return self._ws_connected
|
|
255
|
+
return True # HTTP is stateless; always "connected" if network is up
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def has_pending(self) -> bool:
|
|
259
|
+
"""Check if there are unsent actuator confirmations."""
|
|
260
|
+
with self._confirm_lock:
|
|
261
|
+
return len(self._confirmations) > 0
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Callbacks
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def on_actuator(self, callback: ActuatorCallback) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Register a callback for actuator commands from the server.
|
|
270
|
+
|
|
271
|
+
The callback receives an :class:`ActuatorCommand` (with optional
|
|
272
|
+
``command_text`` for Command-type actuators) and must return:
|
|
273
|
+
- ``1`` — state physically changed (triggers confirmation)
|
|
274
|
+
- ``0`` — already in target state (confirms only if server is pending)
|
|
275
|
+
- ``-1`` — actuator not recognized or could not be applied
|
|
276
|
+
(sends a "mismatch" confirmation + auto error log)
|
|
277
|
+
"""
|
|
278
|
+
self._actuator_cb = callback
|
|
279
|
+
|
|
280
|
+
def on_connection(self, callback: ConnectionCallback) -> None:
|
|
281
|
+
"""Register a callback for WebSocket connection status changes."""
|
|
282
|
+
self._connection_cb = callback
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Sensor Queue
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def queue_reading(self, name: str, value: float) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Queue a numeric sensor reading for transmission.
|
|
291
|
+
|
|
292
|
+
Readings are stored in an offline ring buffer and sent on the next
|
|
293
|
+
:meth:`sync` or :meth:`transmit_readings` call. If the buffer is
|
|
294
|
+
full, the oldest reading is silently dropped.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
name: Sensor name (must match name configured in dashboard).
|
|
298
|
+
value: Current numeric sensor value.
|
|
299
|
+
"""
|
|
300
|
+
self._readings.append(SensorReading(name=name, value=value))
|
|
301
|
+
|
|
302
|
+
def queue_text_reading(self, name: str, text_value: str, media_url: Optional[str] = None) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Queue a text/string sensor reading for transmission.
|
|
305
|
+
|
|
306
|
+
Text readings are stored in ``sensor_text_logs`` on the server.
|
|
307
|
+
Useful for classification results, state labels, etc.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
name: Sensor name (must match name configured in dashboard).
|
|
311
|
+
text_value: The string value (e.g., "Open", "Person Detected").
|
|
312
|
+
media_url: Optional relative storage path for an associated file.
|
|
313
|
+
"""
|
|
314
|
+
self._text_readings.append(
|
|
315
|
+
TextReading(name=name, text_value=text_value, media_url=media_url)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def set_stream_url(self, name: str, url: str) -> None:
|
|
319
|
+
"""
|
|
320
|
+
Set the live stream URL for a stream-type sensor.
|
|
321
|
+
|
|
322
|
+
Convenience wrapper around :meth:`queue_text_reading` for video
|
|
323
|
+
streaming sensors. The URL should point to a live stream hosted on the
|
|
324
|
+
device itself (e.g., MJPEG from a USB camera, or HLS from ffmpeg).
|
|
325
|
+
The dashboard renders the stream directly — no video data is uploaded
|
|
326
|
+
to the server.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
name: Stream sensor name (must match name configured in dashboard).
|
|
330
|
+
url: Full URL of the live stream
|
|
331
|
+
(e.g., ``"http://192.168.1.50:8080/stream"``).
|
|
332
|
+
|
|
333
|
+
Note:
|
|
334
|
+
For HTTPS dashboards, use a tunnel (e.g., Cloudflare Tunnel) to
|
|
335
|
+
expose the device stream over HTTPS and avoid mixed-content blocks.
|
|
336
|
+
|
|
337
|
+
Example::
|
|
338
|
+
|
|
339
|
+
hub.set_stream_url("Camera", f"http://{local_ip}:8080/stream")
|
|
340
|
+
hub.transmit_text_readings()
|
|
341
|
+
"""
|
|
342
|
+
self.queue_text_reading(name, url)
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# Logging
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def queue_log(self, level: LogLevel, message: str) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Queue a log message for transmission.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
level: Log severity level.
|
|
354
|
+
message: Log message (max 500 characters; will be truncated).
|
|
355
|
+
"""
|
|
356
|
+
self._logs.append(
|
|
357
|
+
LogEntry(
|
|
358
|
+
message=message[:500],
|
|
359
|
+
level=level,
|
|
360
|
+
timestamp_ms=time.time() * 1000,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def queue_debug_log(self, message: str) -> None:
|
|
365
|
+
"""Queue a debug log message."""
|
|
366
|
+
self.queue_log(LogLevel.DEBUG, message)
|
|
367
|
+
|
|
368
|
+
def queue_info_log(self, message: str) -> None:
|
|
369
|
+
"""Queue an info log message."""
|
|
370
|
+
self.queue_log(LogLevel.INFO, message)
|
|
371
|
+
|
|
372
|
+
def queue_warning_log(self, message: str) -> None:
|
|
373
|
+
"""Queue a warning log message."""
|
|
374
|
+
self.queue_log(LogLevel.WARN, message)
|
|
375
|
+
|
|
376
|
+
def queue_error_log(self, message: str) -> None:
|
|
377
|
+
"""Queue an error log message."""
|
|
378
|
+
self.queue_log(LogLevel.ERROR, message)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def pending_log_count(self) -> int:
|
|
382
|
+
"""Number of log entries waiting to be sent."""
|
|
383
|
+
return len(self._logs)
|
|
384
|
+
|
|
385
|
+
def set_max_log_queue(self, max_entries: int) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Set maximum number of log entries to queue.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
max_entries: Maximum queue size (capped at 100).
|
|
391
|
+
Oldest entries are dropped when queue is full.
|
|
392
|
+
"""
|
|
393
|
+
capped = min(max_entries, 100)
|
|
394
|
+
self._logs = _RingBuffer(capped)
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
# HTTP Sync (Polling Mode)
|
|
398
|
+
# ------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def sync(self) -> bool:
|
|
401
|
+
"""
|
|
402
|
+
Full HTTP sync: send readings + text readings + logs, receive
|
|
403
|
+
and process actuator commands.
|
|
404
|
+
|
|
405
|
+
This is the recommended method for polling / battery-powered devices.
|
|
406
|
+
Non-blocking — if the network is unreachable, returns ``False``
|
|
407
|
+
immediately without freezing the calling thread.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
``True`` if sync succeeded, ``False`` on error.
|
|
411
|
+
"""
|
|
412
|
+
return self._http_sync(sensors=True, actuators=True, logs=True)
|
|
413
|
+
|
|
414
|
+
def sync_sensors_only(self) -> bool:
|
|
415
|
+
"""Send queued sensor readings only (no actuator commands received)."""
|
|
416
|
+
return self._http_sync(sensors=True, actuators=False, logs=False)
|
|
417
|
+
|
|
418
|
+
def sync_actuators_only(self) -> bool:
|
|
419
|
+
"""Receive actuator commands only (no sensor data or logs sent)."""
|
|
420
|
+
return self._http_sync(sensors=False, actuators=True, logs=False)
|
|
421
|
+
|
|
422
|
+
def sync_logs_only(self) -> bool:
|
|
423
|
+
"""Send only queued logs (no sensors or actuator processing)."""
|
|
424
|
+
return self._http_sync(sensors=False, actuators=False, logs=True)
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
# Background Auto-Sync
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def start_background_sync(self, interval_s: float = 10.0) -> None:
|
|
431
|
+
"""
|
|
432
|
+
Start a background thread that calls :meth:`sync` at regular intervals.
|
|
433
|
+
|
|
434
|
+
This is useful for polling mode — your main loop continues
|
|
435
|
+
uninterrupted while readings and logs are synced automatically.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
interval_s: Seconds between sync attempts (default 10).
|
|
439
|
+
"""
|
|
440
|
+
if self._bg_thread and self._bg_thread.is_alive():
|
|
441
|
+
return # Already running
|
|
442
|
+
|
|
443
|
+
self._bg_stop_event.clear()
|
|
444
|
+
|
|
445
|
+
def _worker() -> None:
|
|
446
|
+
while not self._bg_stop_event.is_set():
|
|
447
|
+
try:
|
|
448
|
+
self.sync()
|
|
449
|
+
except Exception:
|
|
450
|
+
logger.exception("Background sync error")
|
|
451
|
+
self._bg_stop_event.wait(interval_s)
|
|
452
|
+
|
|
453
|
+
self._bg_thread = threading.Thread(target=_worker, daemon=True, name="actu8hub-sync")
|
|
454
|
+
self._bg_thread.start()
|
|
455
|
+
logger.info("Background sync started (interval=%ss)", interval_s)
|
|
456
|
+
|
|
457
|
+
def stop_background_sync(self) -> None:
|
|
458
|
+
"""Stop the background sync thread."""
|
|
459
|
+
self._bg_stop_event.set()
|
|
460
|
+
if self._bg_thread:
|
|
461
|
+
self._bg_thread.join(timeout=5)
|
|
462
|
+
self._bg_thread = None
|
|
463
|
+
logger.info("Background sync stopped")
|
|
464
|
+
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
# WebSocket Methods (Real-Time Mode)
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def connect_websocket(self) -> None:
|
|
470
|
+
"""
|
|
471
|
+
Establish a WebSocket connection for real-time updates.
|
|
472
|
+
|
|
473
|
+
Actuator commands are pushed instantly when changed in the dashboard.
|
|
474
|
+
Runs in a background thread — does not block the calling thread.
|
|
475
|
+
|
|
476
|
+
Requires the ``websocket-client`` package::
|
|
477
|
+
|
|
478
|
+
pip install websocket-client
|
|
479
|
+
"""
|
|
480
|
+
if _ws_mod is None:
|
|
481
|
+
raise ImportError(
|
|
482
|
+
"websocket-client is required for WebSocket mode. "
|
|
483
|
+
"Install it with: pip install websocket-client"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if self._ws_enabled:
|
|
487
|
+
return # Already running
|
|
488
|
+
|
|
489
|
+
self._ws_enabled = True
|
|
490
|
+
self._ws_stop_event.clear()
|
|
491
|
+
self._heartbeat_stop_event.clear()
|
|
492
|
+
self._ws_thread = threading.Thread(
|
|
493
|
+
target=self._ws_run_loop, daemon=True, name="actu8hub-ws"
|
|
494
|
+
)
|
|
495
|
+
self._ws_thread.start()
|
|
496
|
+
self._start_heartbeat_thread()
|
|
497
|
+
|
|
498
|
+
def disconnect_websocket(self) -> None:
|
|
499
|
+
"""Disconnect the WebSocket and stop the background thread."""
|
|
500
|
+
self._ws_enabled = False
|
|
501
|
+
self._ws_stop_event.set()
|
|
502
|
+
self._heartbeat_stop_event.set()
|
|
503
|
+
if self._ws:
|
|
504
|
+
try:
|
|
505
|
+
self._ws.close()
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
if self._ws_thread:
|
|
509
|
+
self._ws_thread.join(timeout=5)
|
|
510
|
+
self._ws_thread = None
|
|
511
|
+
if self._heartbeat_thread:
|
|
512
|
+
self._heartbeat_thread.join(timeout=5)
|
|
513
|
+
self._heartbeat_thread = None
|
|
514
|
+
self._ws_connected = False
|
|
515
|
+
logger.info("WebSocket disconnected")
|
|
516
|
+
|
|
517
|
+
def transmit_readings(self) -> bool:
|
|
518
|
+
"""
|
|
519
|
+
Send queued numeric sensor readings over WebSocket.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
``True`` if sent, ``False`` if not connected or queue empty.
|
|
523
|
+
"""
|
|
524
|
+
if not self._ws_connected or not self._ws:
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
readings_data = self._readings.drain()
|
|
528
|
+
if not readings_data:
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
readings_list: List[Dict[str, Any]] = []
|
|
532
|
+
for r in readings_data:
|
|
533
|
+
readings_list.append({"name": r.name, "value": r.value})
|
|
534
|
+
|
|
535
|
+
msg = json.dumps({"type": "sensor_readings", "readings": readings_list})
|
|
536
|
+
try:
|
|
537
|
+
self._ws.send(msg)
|
|
538
|
+
self._touch_activity()
|
|
539
|
+
return True
|
|
540
|
+
except Exception:
|
|
541
|
+
logger.exception("Failed to transmit readings over WebSocket")
|
|
542
|
+
# Re-queue readings that failed to send
|
|
543
|
+
for r in readings_data:
|
|
544
|
+
self._readings.append(r)
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
def transmit_text_readings(self) -> bool:
|
|
548
|
+
"""
|
|
549
|
+
Send queued text sensor readings.
|
|
550
|
+
|
|
551
|
+
If WebSocket is connected, uses the real-time ``sensor_text_readings``
|
|
552
|
+
message type. Otherwise, falls back to HTTP POST to ``/device-api/sensors``.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
``True`` if sent, ``False`` if queue empty or request failed.
|
|
556
|
+
"""
|
|
557
|
+
text_data = self._text_readings.drain()
|
|
558
|
+
if not text_data:
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
readings_list: List[Dict[str, Any]] = []
|
|
562
|
+
for t in text_data:
|
|
563
|
+
entry: Dict[str, Any] = {"name": t.name, "text_value": t.text_value}
|
|
564
|
+
if t.media_url:
|
|
565
|
+
entry["media_url"] = t.media_url
|
|
566
|
+
readings_list.append(entry)
|
|
567
|
+
|
|
568
|
+
# Try WebSocket first if connected
|
|
569
|
+
if self._ws_connected and self._ws:
|
|
570
|
+
msg = json.dumps({"type": "sensor_text_readings", "readings": readings_list})
|
|
571
|
+
try:
|
|
572
|
+
self._ws.send(msg)
|
|
573
|
+
self._touch_activity()
|
|
574
|
+
return True
|
|
575
|
+
except Exception:
|
|
576
|
+
logger.exception("Failed to transmit text readings over WebSocket")
|
|
577
|
+
# Re-queue on failure and try HTTP fallback
|
|
578
|
+
for t in text_data:
|
|
579
|
+
self._text_readings.append(t)
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
# Fall back to HTTP
|
|
583
|
+
ok = self._post("/sensors", {"readings": readings_list})
|
|
584
|
+
if not ok:
|
|
585
|
+
# Re-queue on failure
|
|
586
|
+
for t in text_data:
|
|
587
|
+
self._text_readings.append(t)
|
|
588
|
+
return ok
|
|
589
|
+
|
|
590
|
+
def transmit_logs(self) -> bool:
|
|
591
|
+
"""
|
|
592
|
+
Send queued logs over WebSocket.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
``True`` if sent, ``False`` if not connected or queue empty.
|
|
596
|
+
"""
|
|
597
|
+
if not self._ws_connected or not self._ws:
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
log_data = self._logs.drain()
|
|
601
|
+
if not log_data:
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
transmit_ms = time.time() * 1000
|
|
605
|
+
logs_list = []
|
|
606
|
+
for entry in log_data:
|
|
607
|
+
logs_list.append({
|
|
608
|
+
"message": entry.message,
|
|
609
|
+
"level": entry.level.value,
|
|
610
|
+
"millis_at_append": entry.timestamp_ms,
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
msg = json.dumps({
|
|
614
|
+
"type": "device_logs",
|
|
615
|
+
"millis_at_transmit": transmit_ms,
|
|
616
|
+
"logs": logs_list,
|
|
617
|
+
})
|
|
618
|
+
try:
|
|
619
|
+
self._ws.send(msg)
|
|
620
|
+
self._touch_activity()
|
|
621
|
+
return True
|
|
622
|
+
except Exception:
|
|
623
|
+
logger.exception("Failed to transmit logs over WebSocket")
|
|
624
|
+
for entry in log_data:
|
|
625
|
+
self._logs.append(entry)
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
# ------------------------------------------------------------------
|
|
629
|
+
# Metadata Reporting
|
|
630
|
+
# ------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def report_metadata(
|
|
633
|
+
self,
|
|
634
|
+
board_type: Optional[str] = None,
|
|
635
|
+
firmware_version: Optional[str] = None,
|
|
636
|
+
sdk_version: Optional[str] = None,
|
|
637
|
+
ip_address: Optional[str] = None,
|
|
638
|
+
) -> bool:
|
|
639
|
+
"""
|
|
640
|
+
Report device metadata to the server.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
board_type: e.g., "Raspberry Pi 4", "Jetson Nano"
|
|
644
|
+
firmware_version: Your application/firmware version string.
|
|
645
|
+
sdk_version: Python client library version.
|
|
646
|
+
ip_address: Device's local IP address.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
``True`` on success, ``False`` on error.
|
|
650
|
+
"""
|
|
651
|
+
payload: Dict[str, str] = {}
|
|
652
|
+
if board_type is not None:
|
|
653
|
+
payload["board_type"] = board_type
|
|
654
|
+
if firmware_version is not None:
|
|
655
|
+
payload["firmware_version"] = firmware_version
|
|
656
|
+
if sdk_version is not None:
|
|
657
|
+
payload["sdk_version"] = sdk_version
|
|
658
|
+
if ip_address is not None:
|
|
659
|
+
payload["ip_address"] = ip_address
|
|
660
|
+
|
|
661
|
+
if not payload:
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
return self._post("/metadata", payload)
|
|
665
|
+
|
|
666
|
+
# ------------------------------------------------------------------
|
|
667
|
+
# File Upload Helper
|
|
668
|
+
# ------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def upload_file(self, filepath: str, sensor_name: str, caption: str = "") -> Optional[str]:
|
|
671
|
+
"""
|
|
672
|
+
Upload a file to Actu8Hub storage and auto-link it to a sensor.
|
|
673
|
+
|
|
674
|
+
The server automatically creates a text log entry for the image,
|
|
675
|
+
so you do NOT need to call :meth:`queue_text_reading` afterwards.
|
|
676
|
+
The image will appear immediately in the dashboard.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
filepath: Path to the local file to upload.
|
|
680
|
+
sensor_name: Name of the sensor to link the image to (must exist on this device).
|
|
681
|
+
caption: Optional caption/description (defaults to filename).
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
The ``media_url`` (relative storage path) on success, or ``None`` on failure.
|
|
685
|
+
|
|
686
|
+
Example::
|
|
687
|
+
|
|
688
|
+
media_path = hub.upload_file("/home/pi/captures/photo.jpg", "Camera", "Person Detected")
|
|
689
|
+
# That's it! No queue_text_reading needed — the image is already linked.
|
|
690
|
+
"""
|
|
691
|
+
if requests is None:
|
|
692
|
+
raise ImportError(
|
|
693
|
+
"The requests library is required. Install it with: pip install requests"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
if not os.path.isfile(filepath):
|
|
697
|
+
logger.error("File not found: %s", filepath)
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
filename = os.path.basename(filepath)
|
|
701
|
+
file_size = os.path.getsize(filepath)
|
|
702
|
+
|
|
703
|
+
# Step 1: Request signed upload URL (sensor_name is required)
|
|
704
|
+
req_body: Dict[str, Any] = {
|
|
705
|
+
"filename": filename,
|
|
706
|
+
"file_size": file_size,
|
|
707
|
+
"sensor_name": sensor_name,
|
|
708
|
+
}
|
|
709
|
+
if caption:
|
|
710
|
+
req_body["caption"] = caption
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
session = self._get_session()
|
|
714
|
+
resp = session.post(
|
|
715
|
+
f"{self._base_url}/device-api/upload-url",
|
|
716
|
+
json=req_body,
|
|
717
|
+
headers=self._headers(),
|
|
718
|
+
timeout=15,
|
|
719
|
+
)
|
|
720
|
+
if resp.status_code != 200:
|
|
721
|
+
logger.error(
|
|
722
|
+
"Failed to get upload URL: %s %s", resp.status_code, resp.text
|
|
723
|
+
)
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
data = resp.json()
|
|
727
|
+
signed_url = data.get("signed_url")
|
|
728
|
+
media_url = data.get("media_url")
|
|
729
|
+
|
|
730
|
+
if not signed_url or not media_url:
|
|
731
|
+
logger.error("Invalid upload-url response: %s", data)
|
|
732
|
+
return None
|
|
733
|
+
except Exception:
|
|
734
|
+
logger.exception("Error requesting upload URL")
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
# Step 2: PUT file to signed URL
|
|
738
|
+
try:
|
|
739
|
+
content_type = mimetypes.guess_type(filepath)[0] or "application/octet-stream"
|
|
740
|
+
with open(filepath, "rb") as f:
|
|
741
|
+
put_resp = session.put(
|
|
742
|
+
signed_url,
|
|
743
|
+
data=f,
|
|
744
|
+
headers={"Content-Type": content_type},
|
|
745
|
+
timeout=60,
|
|
746
|
+
)
|
|
747
|
+
if put_resp.status_code not in (200, 201):
|
|
748
|
+
logger.error(
|
|
749
|
+
"File upload failed: %s %s", put_resp.status_code, put_resp.text
|
|
750
|
+
)
|
|
751
|
+
return None
|
|
752
|
+
except Exception:
|
|
753
|
+
logger.exception("Error uploading file")
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
logger.info("File uploaded: %s -> %s", filepath, media_url)
|
|
757
|
+
return media_url
|
|
758
|
+
|
|
759
|
+
# ------------------------------------------------------------------
|
|
760
|
+
# Device Events
|
|
761
|
+
# ------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
def log_event(self, sensor_name: str, description: str, seconds_ago: int = 0) -> bool:
|
|
764
|
+
"""
|
|
765
|
+
Log a sensor event on the server.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
sensor_name: Name of the sensor this event relates to.
|
|
769
|
+
description: Event description text.
|
|
770
|
+
seconds_ago: How many seconds ago the event occurred (default 0 = now).
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
``True`` on success, ``False`` on error.
|
|
774
|
+
"""
|
|
775
|
+
payload: List[Dict[str, Any]] = [{
|
|
776
|
+
"sensor_name": sensor_name,
|
|
777
|
+
"description": description,
|
|
778
|
+
}]
|
|
779
|
+
if seconds_ago > 0:
|
|
780
|
+
payload[0]["seconds_ago"] = seconds_ago
|
|
781
|
+
|
|
782
|
+
return self._post("/events", payload)
|
|
783
|
+
|
|
784
|
+
# ------------------------------------------------------------------
|
|
785
|
+
# Cleanup
|
|
786
|
+
# ------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
def close(self) -> None:
|
|
789
|
+
"""
|
|
790
|
+
Gracefully shut down all connections and background threads.
|
|
791
|
+
|
|
792
|
+
Call this when your program exits.
|
|
793
|
+
"""
|
|
794
|
+
self.stop_background_sync()
|
|
795
|
+
self.disconnect_websocket()
|
|
796
|
+
if self._session:
|
|
797
|
+
try:
|
|
798
|
+
self._session.close()
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
self._session = None
|
|
802
|
+
|
|
803
|
+
def __enter__(self) -> "Actu8HubClient":
|
|
804
|
+
return self
|
|
805
|
+
|
|
806
|
+
def __exit__(self, *args: Any) -> None:
|
|
807
|
+
self.close()
|
|
808
|
+
|
|
809
|
+
# ==================================================================
|
|
810
|
+
# Private Methods
|
|
811
|
+
# ==================================================================
|
|
812
|
+
|
|
813
|
+
def _headers(self) -> Dict[str, str]:
|
|
814
|
+
"""Common HTTP headers with API key auth."""
|
|
815
|
+
return {
|
|
816
|
+
"Content-Type": "application/json",
|
|
817
|
+
"x-device-api-key": self._api_key,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
def _get_session(self) -> Any:
|
|
821
|
+
"""Lazy-initialise and return a requests.Session for connection reuse."""
|
|
822
|
+
if requests is None:
|
|
823
|
+
raise ImportError(
|
|
824
|
+
"The requests library is required. Install it with: pip install requests"
|
|
825
|
+
)
|
|
826
|
+
if self._session is None:
|
|
827
|
+
self._session = requests.Session()
|
|
828
|
+
return self._session
|
|
829
|
+
|
|
830
|
+
def _post(self, path: str, payload: Any) -> bool:
|
|
831
|
+
"""POST JSON to a device-api endpoint. Returns True on 2xx."""
|
|
832
|
+
try:
|
|
833
|
+
session = self._get_session()
|
|
834
|
+
resp = session.post(
|
|
835
|
+
f"{self._base_url}/device-api{path}",
|
|
836
|
+
json=payload,
|
|
837
|
+
headers=self._headers(),
|
|
838
|
+
timeout=15,
|
|
839
|
+
)
|
|
840
|
+
return 200 <= resp.status_code < 300
|
|
841
|
+
except Exception:
|
|
842
|
+
logger.exception("HTTP POST %s failed", path)
|
|
843
|
+
return False
|
|
844
|
+
|
|
845
|
+
# ------------------------------------------------------------------
|
|
846
|
+
# HTTP Sync Implementation
|
|
847
|
+
# ------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
def _http_sync(self, sensors: bool, actuators: bool, logs: bool) -> bool:
|
|
850
|
+
"""
|
|
851
|
+
Single-call HTTP sync matching the ``/device-api/sync`` endpoint.
|
|
852
|
+
|
|
853
|
+
Sends readings + text readings + confirmations + logs in one POST
|
|
854
|
+
and receives actuator state in the response.
|
|
855
|
+
"""
|
|
856
|
+
try:
|
|
857
|
+
session = self._get_session()
|
|
858
|
+
except ImportError:
|
|
859
|
+
logger.error("requests library not installed")
|
|
860
|
+
return False
|
|
861
|
+
|
|
862
|
+
payload: Dict[str, Any] = {}
|
|
863
|
+
|
|
864
|
+
# Drain queues upfront so variables are always defined
|
|
865
|
+
readings_data: List[Any] = []
|
|
866
|
+
text_data: List[Any] = []
|
|
867
|
+
log_data: List[Any] = []
|
|
868
|
+
|
|
869
|
+
# Sensor readings (numeric + text combined)
|
|
870
|
+
if sensors:
|
|
871
|
+
readings_data = self._readings.drain()
|
|
872
|
+
text_data = self._text_readings.drain()
|
|
873
|
+
|
|
874
|
+
if readings_data or text_data:
|
|
875
|
+
readings_list: List[Dict[str, Any]] = []
|
|
876
|
+
for r in readings_data:
|
|
877
|
+
readings_list.append({"name": r.name, "value": r.value})
|
|
878
|
+
for t in text_data:
|
|
879
|
+
entry: Dict[str, Any] = {"name": t.name, "text_value": t.text_value}
|
|
880
|
+
if t.media_url:
|
|
881
|
+
entry["media_url"] = t.media_url
|
|
882
|
+
readings_list.append(entry)
|
|
883
|
+
payload["sensor_readings"] = readings_list
|
|
884
|
+
|
|
885
|
+
# Pending confirmations
|
|
886
|
+
with self._confirm_lock:
|
|
887
|
+
if self._confirmations:
|
|
888
|
+
confs = []
|
|
889
|
+
for c in self._confirmations:
|
|
890
|
+
confs.append({
|
|
891
|
+
"name": c.name,
|
|
892
|
+
"is_active": c.is_active,
|
|
893
|
+
"value": c.value,
|
|
894
|
+
"status": c.status,
|
|
895
|
+
})
|
|
896
|
+
payload["actuator_confirmations"] = confs
|
|
897
|
+
self._confirmations.clear()
|
|
898
|
+
|
|
899
|
+
# Logs — server expects { millis_at_transmit, logs: [...] }
|
|
900
|
+
if logs:
|
|
901
|
+
log_data = self._logs.drain()
|
|
902
|
+
if log_data:
|
|
903
|
+
transmit_ms = time.time() * 1000
|
|
904
|
+
logs_list = []
|
|
905
|
+
for entry in log_data:
|
|
906
|
+
logs_list.append({
|
|
907
|
+
"message": entry.message,
|
|
908
|
+
"level": entry.level.value,
|
|
909
|
+
"millis_at_append": entry.timestamp_ms,
|
|
910
|
+
})
|
|
911
|
+
payload["device_logs"] = {
|
|
912
|
+
"millis_at_transmit": transmit_ms,
|
|
913
|
+
"logs": logs_list,
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
try:
|
|
917
|
+
resp = session.post(
|
|
918
|
+
f"{self._base_url}/device-api/sync",
|
|
919
|
+
json=payload,
|
|
920
|
+
headers=self._headers(),
|
|
921
|
+
timeout=15,
|
|
922
|
+
)
|
|
923
|
+
except Exception:
|
|
924
|
+
logger.exception("HTTP sync failed")
|
|
925
|
+
# Re-queue drained data so it's not lost
|
|
926
|
+
for r in readings_data:
|
|
927
|
+
self._readings.append(r)
|
|
928
|
+
for t in text_data:
|
|
929
|
+
self._text_readings.append(t)
|
|
930
|
+
for entry in log_data:
|
|
931
|
+
self._logs.append(entry)
|
|
932
|
+
return False
|
|
933
|
+
|
|
934
|
+
if resp.status_code != 200:
|
|
935
|
+
logger.warning("Sync returned HTTP %s: %s", resp.status_code, resp.text[:200])
|
|
936
|
+
return False
|
|
937
|
+
|
|
938
|
+
# Process actuator commands from response
|
|
939
|
+
if actuators:
|
|
940
|
+
try:
|
|
941
|
+
data = resp.json()
|
|
942
|
+
for act in data.get("actuators", []):
|
|
943
|
+
self._process_actuator(act)
|
|
944
|
+
except Exception:
|
|
945
|
+
logger.exception("Error processing sync response")
|
|
946
|
+
|
|
947
|
+
return True
|
|
948
|
+
|
|
949
|
+
# ------------------------------------------------------------------
|
|
950
|
+
# Actuator Processing
|
|
951
|
+
# ------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
def _process_actuator(self, act: Dict[str, Any]) -> None:
|
|
954
|
+
"""
|
|
955
|
+
Parse actuator JSON and invoke callback.
|
|
956
|
+
|
|
957
|
+
Smart confirmation:
|
|
958
|
+
- callback 1 → confirm with status="ok"
|
|
959
|
+
- callback 0 → confirm with status="ok" only if server still pending
|
|
960
|
+
- callback -1 → confirm with status="mismatch" + auto error log
|
|
961
|
+
"""
|
|
962
|
+
if not self._actuator_cb:
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
cmd = ActuatorCommand(
|
|
966
|
+
name=act.get("name", ""),
|
|
967
|
+
is_active=bool(act.get("is_active", False)),
|
|
968
|
+
value=float(act.get("value", 0)),
|
|
969
|
+
command_text=str(act.get("command_text") or ""),
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
server_thinks_confirmed = act.get("device_confirmed", False)
|
|
973
|
+
try:
|
|
974
|
+
result = self._actuator_cb(cmd)
|
|
975
|
+
except Exception:
|
|
976
|
+
logger.exception("Actuator callback raised; treating as mismatch")
|
|
977
|
+
result = -1
|
|
978
|
+
|
|
979
|
+
if result == 1 or (result == 0 and not server_thinks_confirmed):
|
|
980
|
+
with self._confirm_lock:
|
|
981
|
+
self._confirmations.append(
|
|
982
|
+
_PendingConfirmation(
|
|
983
|
+
name=cmd.name,
|
|
984
|
+
is_active=cmd.is_active,
|
|
985
|
+
value=cmd.value,
|
|
986
|
+
status="ok",
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
elif result == -1:
|
|
990
|
+
with self._confirm_lock:
|
|
991
|
+
self._confirmations.append(
|
|
992
|
+
_PendingConfirmation(
|
|
993
|
+
name=cmd.name,
|
|
994
|
+
is_active=cmd.is_active,
|
|
995
|
+
value=cmd.value,
|
|
996
|
+
status="mismatch",
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
self.queue_error_log(f"Unrecognized actuator '{cmd.name}'")
|
|
1000
|
+
|
|
1001
|
+
# ------------------------------------------------------------------
|
|
1002
|
+
# WebSocket Implementation
|
|
1003
|
+
# ------------------------------------------------------------------
|
|
1004
|
+
|
|
1005
|
+
def _ws_run_loop(self) -> None:
|
|
1006
|
+
"""Background thread: connect WebSocket and handle reconnection."""
|
|
1007
|
+
while self._ws_enabled and not self._ws_stop_event.is_set():
|
|
1008
|
+
try:
|
|
1009
|
+
ws_url = (
|
|
1010
|
+
f"{self._ws_url}/device-realtime"
|
|
1011
|
+
f"?api_key={self._api_key}"
|
|
1012
|
+
)
|
|
1013
|
+
self._ws = _ws_mod.WebSocketApp( # type: ignore[union-attr]
|
|
1014
|
+
ws_url,
|
|
1015
|
+
on_open=self._on_ws_open,
|
|
1016
|
+
on_message=self._on_ws_message,
|
|
1017
|
+
on_error=self._on_ws_error,
|
|
1018
|
+
on_close=self._on_ws_close,
|
|
1019
|
+
)
|
|
1020
|
+
self._ws.run_forever(
|
|
1021
|
+
sslopt={"cert_reqs": ssl.CERT_NONE},
|
|
1022
|
+
ping_interval=None,
|
|
1023
|
+
ping_timeout=10,
|
|
1024
|
+
)
|
|
1025
|
+
except Exception:
|
|
1026
|
+
logger.exception("WebSocket connection error")
|
|
1027
|
+
|
|
1028
|
+
# If still enabled, wait before reconnecting
|
|
1029
|
+
if self._ws_enabled and not self._ws_stop_event.is_set():
|
|
1030
|
+
logger.info(
|
|
1031
|
+
"WebSocket reconnecting in %ss...", _RECONNECT_INTERVAL_S
|
|
1032
|
+
)
|
|
1033
|
+
self._ws_stop_event.wait(_RECONNECT_INTERVAL_S)
|
|
1034
|
+
|
|
1035
|
+
def _on_ws_open(self, ws: Any) -> None:
|
|
1036
|
+
self._ws_connected = True
|
|
1037
|
+
self._touch_activity()
|
|
1038
|
+
logger.info("WebSocket connected")
|
|
1039
|
+
if self._connection_cb:
|
|
1040
|
+
try:
|
|
1041
|
+
self._connection_cb(True)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
logger.exception("Connection callback error")
|
|
1044
|
+
|
|
1045
|
+
def _on_ws_message(self, ws: Any, message: str) -> None:
|
|
1046
|
+
try:
|
|
1047
|
+
data = json.loads(message)
|
|
1048
|
+
except json.JSONDecodeError:
|
|
1049
|
+
logger.warning("Invalid WebSocket message: %s", message[:100])
|
|
1050
|
+
return
|
|
1051
|
+
|
|
1052
|
+
self._touch_activity()
|
|
1053
|
+
|
|
1054
|
+
msg_type = data.get("type", "")
|
|
1055
|
+
|
|
1056
|
+
if msg_type == "connected":
|
|
1057
|
+
# Initial state — process all current actuator states
|
|
1058
|
+
for act in data.get("actuators", []):
|
|
1059
|
+
self._process_actuator(act)
|
|
1060
|
+
self._ws_send_confirmations()
|
|
1061
|
+
|
|
1062
|
+
elif msg_type == "actuator_update":
|
|
1063
|
+
# Real-time single actuator change
|
|
1064
|
+
actuator_data = data.get("actuator", {})
|
|
1065
|
+
self._process_actuator(actuator_data)
|
|
1066
|
+
self._ws_send_confirmations()
|
|
1067
|
+
|
|
1068
|
+
elif msg_type in (
|
|
1069
|
+
"heartbeat_ack",
|
|
1070
|
+
"sensor_readings_ack",
|
|
1071
|
+
"device_logs_ack",
|
|
1072
|
+
"actuator_confirm_ack",
|
|
1073
|
+
"sensor_stream_ack",
|
|
1074
|
+
):
|
|
1075
|
+
# Acknowledgements — logged at debug level
|
|
1076
|
+
logger.debug("Received %s", msg_type)
|
|
1077
|
+
|
|
1078
|
+
else:
|
|
1079
|
+
logger.debug("Unknown WebSocket message type: %s", msg_type)
|
|
1080
|
+
|
|
1081
|
+
def _on_ws_error(self, ws: Any, error: Any) -> None:
|
|
1082
|
+
logger.warning("WebSocket error: %s", error)
|
|
1083
|
+
|
|
1084
|
+
def _on_ws_close(self, ws: Any, close_status_code: Any, close_msg: Any) -> None:
|
|
1085
|
+
was_connected = self._ws_connected
|
|
1086
|
+
self._ws_connected = False
|
|
1087
|
+
if was_connected:
|
|
1088
|
+
logger.info("WebSocket disconnected (code=%s)", close_status_code)
|
|
1089
|
+
if self._connection_cb:
|
|
1090
|
+
try:
|
|
1091
|
+
self._connection_cb(False)
|
|
1092
|
+
except Exception:
|
|
1093
|
+
logger.exception("Connection callback error")
|
|
1094
|
+
|
|
1095
|
+
def _ws_send_confirmations(self) -> None:
|
|
1096
|
+
"""Send pending actuator confirmations over WebSocket."""
|
|
1097
|
+
with self._confirm_lock:
|
|
1098
|
+
if not self._confirmations or not self._ws_connected or not self._ws:
|
|
1099
|
+
return
|
|
1100
|
+
confs = list(self._confirmations)
|
|
1101
|
+
self._confirmations.clear()
|
|
1102
|
+
|
|
1103
|
+
actuators_list = []
|
|
1104
|
+
for c in confs:
|
|
1105
|
+
actuators_list.append({
|
|
1106
|
+
"name": c.name,
|
|
1107
|
+
"is_active": c.is_active,
|
|
1108
|
+
"value": c.value,
|
|
1109
|
+
"status": c.status,
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
msg = json.dumps({"type": "actuator_confirm", "actuators": actuators_list})
|
|
1113
|
+
try:
|
|
1114
|
+
self._ws.send(msg)
|
|
1115
|
+
self._touch_activity()
|
|
1116
|
+
except Exception:
|
|
1117
|
+
logger.exception("Failed to send confirmations")
|
|
1118
|
+
# Re-queue on failure
|
|
1119
|
+
with self._confirm_lock:
|
|
1120
|
+
self._confirmations.extend(confs)
|
|
1121
|
+
|
|
1122
|
+
def _start_heartbeat_thread(self) -> None:
|
|
1123
|
+
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
|
1124
|
+
return
|
|
1125
|
+
|
|
1126
|
+
def _worker() -> None:
|
|
1127
|
+
while self._ws_enabled and not self._heartbeat_stop_event.is_set():
|
|
1128
|
+
if self._ws_connected and self._ws:
|
|
1129
|
+
idle_for = time.time() - self._last_activity
|
|
1130
|
+
if idle_for >= _HEARTBEAT_INTERVAL_S:
|
|
1131
|
+
self._send_heartbeat()
|
|
1132
|
+
self._heartbeat_stop_event.wait(1.0)
|
|
1133
|
+
|
|
1134
|
+
self._heartbeat_thread = threading.Thread(
|
|
1135
|
+
target=_worker, daemon=True, name="actu8hub-ws-heartbeat"
|
|
1136
|
+
)
|
|
1137
|
+
self._heartbeat_thread.start()
|
|
1138
|
+
|
|
1139
|
+
def _send_heartbeat(self) -> None:
|
|
1140
|
+
if not self._ws_connected or not self._ws:
|
|
1141
|
+
return
|
|
1142
|
+
try:
|
|
1143
|
+
self._ws.send(json.dumps({"type": "heartbeat"}))
|
|
1144
|
+
self._touch_activity()
|
|
1145
|
+
except Exception:
|
|
1146
|
+
pass
|
|
1147
|
+
|
|
1148
|
+
def _touch_activity(self) -> None:
|
|
1149
|
+
self._last_activity = time.time()
|