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.
@@ -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()