Telemetry
Build, push image, and notify Watchtower / build-image (push) Successful in 1m28s
Build, push image, and notify Watchtower / notify (push) Successful in 19s

This commit is contained in:
2026-08-10 14:00:26 +02:00
parent 0bc1a88e3c
commit 4d3184cd2f
6 changed files with 271 additions and 26 deletions
+61
View File
@@ -0,0 +1,61 @@
from datetime import datetime
import asyncio
import json
async def get_systemd_logs(lines: int = 200, service_name: str = "smartwave") -> list[dict]:
"""
Asynchronously fetches the last N log entries from a systemd service
using native asyncio subprocess execution.
"""
cmd = (
"journalctl",
"-u", service_name,
"-n", str(lines),
"-o", "json",
"--no-pager"
)
try:
# Create non-blocking child process
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Await completion and read output non-blockingly
stdout_bytes, stderr_bytes = await proc.communicate()
if proc.returncode != 0:
print(f"[Logs] journalctl error (code {proc.returncode}): {stderr_bytes.decode('utf-8', errors='replace')}")
return []
structured_logs = []
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
for line in stdout_text.strip().split("\n"):
if not line:
continue
try:
entry = json.loads(line)
raw_ts = entry.get("__REALTIME_TIMESTAMP")
timestamp_iso = None
if raw_ts:
timestamp_iso = datetime.fromtimestamp(int(raw_ts) / 1_000_000).isoformat()
structured_logs.append({
"timestamp": timestamp_iso,
"timestamp_us": int(raw_ts) if raw_ts else None,
"message": entry.get("MESSAGE", ""),
"priority": entry.get("PRIORITY"),
"pid": entry.get("_PID"),
})
except (json.JSONDecodeError, ValueError):
continue
return structured_logs
except Exception as e:
print(f"[Logs] Failed to read systemd logs for '{service_name}': {e}")
return []
+124 -11
View File
@@ -1,7 +1,6 @@
import base64
import json
import time
import traceback
import asyncio
import requests
@@ -10,7 +9,8 @@ from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from sensors import ultrasonicRanger, temp_hum, button, camera
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
from lib.systemd_logs import get_systemd_logs
# --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite"
@@ -39,6 +39,12 @@ def save_connected_component(component_id: str, component_type: str):
"""
db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time))
print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}")
def get_connected_components():
"""Returns a list of all connected components from the database."""
sql = "SELECT id, type, timestamp FROM connected_components;"
rows = db.fetchall(DB_PATH, sql)
return [{"id": row[0], "type": row[1], "timestamp": row[2]} for row in rows]
# --- Read Unique Device ID ---
def get_device_id():
@@ -71,10 +77,11 @@ ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
# --- HARDWARE SETUP ---
# --- HARDWARE & MQTT SETUP ---
lora = get_lora()
lora.configure()
# Local MQTT Broker (ESP32 IR & Sensors)
mqtt_client = get_mqtt_client(
host="192.168.50.1",
client_id="smartwave-orchestrateur-" + DEVICE_ID,
@@ -88,7 +95,25 @@ mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
if hasattr(mqtt_client._client, "loop_start"):
mqtt_client._client.loop_start()
print("[MQTT] Paho background loop started.")
print("[MQTT Local] Paho background loop started.")
# Cloud MQTT Broker (Remote Commands)
cloud_mqtt_client = get_mqtt_client(
host="mqtt.matthiasg.dev",
client_id=DEVICE_ID,
use_tls=False,
username="microwave_device",
password="myMicrowaveVerySecret",
keepalive=config.MQTT_KEEPALIVE,
)
cloud_mqtt_client.connect()
cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS)
cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS)
if hasattr(cloud_mqtt_client._client, "loop_start"):
cloud_mqtt_client._client.loop_start()
print("[MQTT Cloud] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) ---
async def lora_listener_task():
@@ -102,8 +127,8 @@ async def lora_listener_task():
await asyncio.sleep(0.05)
async def mqtt_listener_task():
"""Polls MQTT cache and pushes to the async queue."""
print("[MQTT] Async listener started.")
"""Polls local MQTT cache and pushes to the async queue."""
print("[MQTT Local] Async listener started.")
while True:
message = mqtt_client.get_message()
if message:
@@ -124,6 +149,28 @@ async def mqtt_listener_task():
})
await asyncio.sleep(0.1)
async def cloud_mqtt_listener_task():
"""Polls Cloud MQTT cache and pushes commands to the async queue."""
print("[MQTT Cloud] Async listener started.")
while True:
message = cloud_mqtt_client.get_message()
if message:
try:
payload = json.loads(message['payload'])
except Exception:
payload = message['payload']
topic = message['topic']
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
await async_event_queue.put({
"source": "CloudMQTT",
"topic": topic,
"data": payload
})
await asyncio.sleep(0.1)
def button_callback():
"""Button physical interrupt callback."""
global button_state
@@ -288,6 +335,10 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
# --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
@@ -309,10 +360,6 @@ async def process_messages_task():
elif source == "MQTT":
topic = msg["topic"]
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
@@ -342,6 +389,68 @@ async def process_messages_task():
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
asyncio.create_task(handle_telemetry_request(endpoint))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
async def handle_telemetry_request(endpoint: str):
"""
Task to gather telemetry data (including structured systemd logs)
and post it to the target HTTP endpoint.
"""
print(f"[Telemetry] Starting telemetry collection for endpoint: {endpoint}")
try:
# 1. Fetch systemd logs asynchronously
logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
# 2. Unpack temperature and humidity from sensors.temp_hum
ambient_temp, ambient_humidity = temp_hum.get_temperature_and_humidity_with_retry()
# 3. Build the telemetry payload
telemetry_payload = payloads.telemetry_payload(
device_id=DEVICE_ID,
microwave_states=microwave_states,
button_state=button_state,
cloud_alert=cloud_alert,
gps_data=gps.get_gps_data(),
ambient_temp=ambient_temp,
ambient_humidity=ambient_humidity,
connected_components=get_connected_components(),
logs=logs_list
)
print(telemetry_payload)
print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...")
# 4. Offload blocking HTTP POST to thread pool
response = await asyncio.to_thread(
requests.post,
endpoint,
json=telemetry_payload,
headers={"Content-Type": "application/json"},
timeout=15
)
response.raise_for_status()
print(f"[Telemetry] Successfully dispatched telemetry (HTTP {response.status_code}).")
except requests.exceptions.RequestException as req_err:
print(f"[Telemetry] HTTP Request failed to {endpoint}: {req_err}")
except Exception as e:
print(f"[Telemetry] Error handling telemetry request: {e}")
async def get_filtered_dish_height(samples=3, delay=0.04):
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
valid_samples = []
@@ -418,6 +527,7 @@ async def main():
await asyncio.gather(
lora_listener_task(),
mqtt_listener_task(),
cloud_mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task()
)
@@ -430,4 +540,7 @@ if __name__ == "__main__":
finally:
if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop()
mqtt_client.close()
if hasattr(cloud_mqtt_client._client, "loop_stop"):
cloud_mqtt_client._client.loop_stop()
mqtt_client.close()
cloud_mqtt_client.close()