overhaul ble.py, move all async calls there

This commit is contained in:
matt committed 2026-10-14 04:49:43 -07:00
1 parent 87cd24234a
commit 0d975a15a8
8 files changed
+711 -134

No files matched your search

+4 -2
View File
@@ -5,7 +5,6 @@ from flask_apscheduler import APScheduler
from datetime import datetime, timedelta
from scanner import *
from config import *
from ble_redis import *
import logging
logging.getLogger('apscheduler').setLevel(logging.ERROR)
@@ -14,7 +13,7 @@ app = Flask(__name__)
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.start()
run_counter = 0
scan_macs()
# --- Scheduler job ----------------------------------------------------
@@ -25,8 +24,11 @@ scan_macs()
misfire_grace_time=30
)
def ble_scan_job():
run_counter = run_counter + 1
"""Runs in the background, calls the async scanner."""
update_cache()
#if run_counter > 9:
# run_command
# --- API -------------------------------------------------------------
+124
View File
@@ -0,0 +1,124 @@
import asyncio
from bleak import BleakClient, BleakScanner
import warnings, logging
warnings.filterwarnings("ignore")
logging.getLogger('apscheduler').setLevel(logging.ERROR)
# Mi sensor UUIDs
MI_SERVICE_UUID = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
# scanner constants
full_timeout = 60
attempts = 6
async def read_data(mac_address):
# Scan for the device
ratio = full_timeout / attempts
fail_count = full_timeout / ratio
device = None
print(f"Beginning read_data for {mac_address} - {full_timeout} timeout - {ratio} ratio - please stand by...")
while fail_count > 0 and not device:
# print(f"Starting discover loop {fail_count}")
devices = await BleakScanner.discover(timeout=ratio)
# print("BleakScanner discover timeout pass")
device = next((d for d in devices if d.address.lower() == mac_address.lower()), None)
if device:
# print(f"BleakScanner device variable loaded - {device.name}")
print(f".. Found device: {device.name} ({device.address}) - connecting...")
fail_count = 0
# else:
# print("BleakScanner device not loaded yet")
fail_count = fail_count - 1
if not device:
print(f"!! Device with MAC {mac_address} not found")
return None
# Connect to the device
mi_service = None
async with BleakClient(device) as client:
success = False
# Check if Mi service is available
#print(".. Waiting on client to connect...")
while not client.is_connected:
await asyncio.sleep(0.1)
while not success:
try:
#print(".. Client connected, getting services")
services = client.services
#print(".. Services acquired, loading uuid matches")
mi_service = next((s for s in services if s.uuid.lower() == MI_SERVICE_UUID.lower()), None)
success = True
#print("uuid matches loaded")
except Exception as e:
print(f"Mi service unavailable, infinite loop looping RIP - {e}")
# Read temperature data
sensor_data = await client.read_gatt_char(TEMP_CHAR_UUID)
# Parse the temperature value (same logic as in JavaScript)
sign = sensor_data[1] & (1 << 7)
temp = ((sensor_data[1] & 0x7F) << 8 | sensor_data[0])
if sign:
temp = temp - 32767
temp = temp / 100
hum = sensor_data[2]
print(f"-- Temperature: {temp:.1f}°C, Humidity: {hum}%")
return temp, hum
def sensor_data_query(mac_list):
result = []
for mac in mac_list:
try:
this_result = asyncio.run(read_data(mac))
except Exception as e:
print(f"Error reading BLE data - {e}")
result.append({"name": mac, "temp": this_result[0], "humidity": this_result[1]})
print(f"Data query complete - returning result")
return result
def test():
test_result = False
sensors = []
mac_list = [
"A4:C1:38:11:F4:2A",
"A4:C1:38:24:15:B0",
"A4:C1:38:D7:8D:26"
]
print("Starting BLE test...")
print(" Scanning MACs:")
for mac in mac_list:
print(f" {mac}")
test_list = mac_list
while not test_result:
print(" Test loop start. Initializing run...")
test_run_results = sensor_data_query(test_list)
for test_run_result in test_run_results:
sensors.append(test_run_result)
passed_list = [device['name'] for device in sensors]
print(f" Passed list - {passed_list}")
if len(mac_list) == len(passed_list):
print(" Test run passed, yee-haw")
test_result = True
else:
print(f" Test run incomplete/failed, re-running, updating test list")
mac_list_new = []
for mac in mac_list:
if mac not in passed_list:
mac_list_new.append(mac)
test_list = mac_list_new
print(f" Re-do list - {test_list}")
if __name__ == "__main__":
#print("Welp, this will keep going forever. Have fun :)")
#while True:
#try:
test()
#except Exception as e:
# print(f"Test failed brah - {e}")
+216 -68
View File
@@ -1,94 +1,230 @@
#!/usr/bin/env python3
"""
mi_ble_reader
==============
Async BLE reader for Xiaomi Mi temperature / humidity sensors.
This module keeps the original helper names (`sensor_data_query`, `test`, etc.) so
you can keep your existing code unchanged. In addition it exposes a minimal
public API that other apps can import:
* `read_data` - async coroutine, returns SensorReading or None
* `read_multiple` - async coroutine, reads a list of MACs concurrently
* `read_data_sync` - sync wrapper around `read_data` (creates its own loop)
All heavy lifting is done in the async functions; the sync wrapper is only
provided for convenience.
"""
import asyncio
from bleak import BleakClient, BleakScanner
import warnings, logging
warnings.filterwarnings("ignore")
logging.getLogger('apscheduler').setLevel(logging.ERROR)
from dataclasses import dataclass
from typing import List, Optional
from config import *
from bleak import BleakClient, BleakScanner
# Mi sensor UUIDs
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
MI_SERVICE_UUID = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
# scanner constants
full_timeout = 60
attempts = 6
SCAN_TIMEOUT = 5.0 # seconds to scan for each MAC
CONNECT_TIMEOUT = 10.0
READ_TIMEOUT = 2.0
SERVICE_DISCOVERY_TIMEOUT = 5.0
_lock = asyncio.Lock()
async def read_data(mac_address):
# --------------------------------------------------------------------------- #
# Logging
# --------------------------------------------------------------------------- #
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)
# Scan for the device
ratio = full_timeout / attempts
fail_count = full_timeout / ratio
device = None
print(f"Beginning read_data for {mac_address} - {full_timeout} timeout - {ratio} ratio - please stand by...")
while fail_count > 0 and not device:
# print(f"Starting discover loop {fail_count}")
devices = await BleakScanner.discover(timeout=ratio)
# print("BleakScanner discover timeout pass")
device = next((d for d in devices if d.address.lower() == mac_address.lower()), None)
if device:
# print(f"BleakScanner device variable loaded - {device.name}")
print(f".. Found device: {device.name} ({device.address}) - connecting...")
fail_count = 0
# else:
# print("BleakScanner device not loaded yet")
fail_count = fail_count - 1
# --------------------------------------------------------------------------- #
# Data model
# --------------------------------------------------------------------------- #
@dataclass
class SensorReading:
"""Single Mi sensor reading."""
mac: str
temperature: float
humidity: int
if not device:
print(f"!! Device with MAC {mac_address} not found")
# --------------------------------------------------------------------------- #
# Helpers - kept identical to your original code
# --------------------------------------------------------------------------- #
async def _discover_device(mac: str) -> Optional[str]:
"""Scan once for the given MAC and return its address if found."""
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
for d in devices:
if d.address.lower() == mac.lower():
log.info("Found device %s (%s)", d.name or d.address, d.address)
return d.address
log.warning("Device %s not found in scan", mac)
return None
def _parse_sensor_data(raw: bytes) -> (float, int):
"""Parse the 3-byte Mi packet into temperature & humidity."""
sign = raw[1] & (1 << 7)
temp = ((raw[1] & 0x7F) << 8 | raw[0])
if sign:
temp -= 32767
temp = temp / 100.0
hum = raw[2]
return temp, hum
# --------------------------------------------------------------------------- #
# Optional device discovery helper
# --------------------------------------------------------------------------- #
async def _scan_once() -> List[str]:
"""
Scan once for nearby BLE devices and return the MAC addresses of those
that satisfy *filter_fn*.
Parameters
----------
filter_fn : Callable[[address, name], bool] | None
If supplied, a predicate that receives the device address and name
and returns ``True`` when the device should be kept.
When ``None`` all discovered addresses are returned.
Returns
-------
List[str]
A list of MAC addresses (upper-case as returned by Bleak).
"""
log.debug("Starting one-shot BLE scan (timeout = %.1fs)", SCAN_TIMEOUT)
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
return [dev.address for dev in devices if _is_match(dev.address, dev.name)]
# this function filters MACs
def _is_match(mac: str, name: str) -> bool:
"""Return True if the MAC starts with the configured prefix and is correct model."""
if name == "LYWSD03MMC":
return mac.upper().startswith(MAC_PREFIX.upper())
# this helper function scans for MACs
def scan_macs():
print("Scanning for MACs:")
new_macs = asyncio.run(_scan_once())
print(f"Found {len(new_macs)} new MACs.")
return new_macs
# --------------------------------------------------------------------------- #
# Core async read functions (unchanged from the refactored version)
# --------------------------------------------------------------------------- #
async def read_data(mac: str) -> Optional[SensorReading]:
address = await _discover_device(mac)
if not address:
return None
# Connect to the device
mi_service = None
async with BleakClient(device) as client:
success = False
# Check if Mi service is available
#print(".. Waiting on client to connect...")
while not client.is_connected:
await asyncio.sleep(0.1)
while not success:
try:
async with BleakClient(address, timeout=CONNECT_TIMEOUT) as client:
# <--- NEW BLOCK ----------------------------------------------
try:
#print(".. Client connected, getting services")
services = client.services
#print(".. Services acquired, loading uuid matches")
mi_service = next((s for s in services if s.uuid.lower() == MI_SERVICE_UUID.lower()), None)
success = True
#print("uuid matches loaded")
await asyncio.wait_for(client.get_services(),
timeout=SERVICE_DISCOVERY_TIMEOUT)
except asyncio.TimeoutError:
log.warning("Service discovery timed out for %s", mac)
return None
except Exception as e:
print(f"Mi service unavailable, infinite loop looping RIP - {e}")
print(f"Discovery failed - {e}")
# ---------------------------------------------------------------
# Read temperature data
sensor_data = await client.read_gatt_char(TEMP_CHAR_UUID)
# If we get here, services are available.
if MI_SERVICE_UUID.lower() not in (
s.uuid.lower() for s in client.services
):
log.error("Mi service not found on %s", mac)
return None
# Parse the temperature value (same logic as in JavaScript)
sign = sensor_data[1] & (1 << 7)
temp = ((sensor_data[1] & 0x7F) << 8 | sensor_data[0])
if sign:
temp = temp - 32767
temp = temp / 100
hum = sensor_data[2]
raw = await asyncio.wait_for(
client.read_gatt_char(TEMP_CHAR_UUID),
timeout=READ_TIMEOUT,
)
temp, hum = _parse_sensor_data(raw)
log.info("Read from %s: %.1f°C, %d%%", mac, temp, hum)
return SensorReading(mac=mac, temperature=temp, humidity=hum)
print(f"-- Temperature: {temp:.1f}°C, Humidity: {hum}%")
return temp, hum
except asyncio.TimeoutError:
# This is the *connect* timeout (BleakClient)
log.error("Connection to %s timed out after %.1fs", mac, CONNECT_TIMEOUT)
except Exception as exc:
log.exception("Error reading from %s: %s", mac, exc)
return None
def sensor_data_query(mac_list):
async def read_multiple(mac_list: List[str]) -> List[SensorReading]:
"""
Read all MACs concurrently. Skips any that fail.
"""
tasks = [asyncio.create_task(read_data(mac)) for mac in mac_list]
results = await asyncio.gather(*tasks, return_exceptions=False)
readings = [r for r in results if r is not None]
failed = [mac for mac, r in zip(mac_list, results) if r is None]
if failed:
log.warning("Failed to read: %s", ", ".join(failed))
return readings
# --------------------------------------------------------------------------- #
# Sync wrapper (kept for legacy code that cannot run an event loop)
# --------------------------------------------------------------------------- #
def read_data_sync(mac: str) -> Optional[SensorReading]:
"""
Convenience wrapper that runs ``read_data`` in a fresh event-loop.
Use this from non-async code.
"""
return asyncio.run(read_data(mac))
# --------------------------------------------------------------------------- #
# Original helper functions - unchanged
# --------------------------------------------------------------------------- #
def sensor_data_query(mac_list: List[str]) -> List[dict]:
"""Legacy function; returns a list of dicts like the original code."""
result = []
for mac in mac_list:
try:
this_result = asyncio.run(read_data(mac))
except Exception as e:
print(f"Error reading BLE data - {e}")
result.append({"name": mac, "temp": this_result[0], "humidity": this_result[1]})
this_result = None
if this_result:
result.append(
{"name": mac, "temp": this_result.temperature, "humidity": this_result.humidity}
)
print(f"Data query complete - returning result")
return result
def test():
def test(test_count = 0) -> None:
"""Legacy test harness - identical to your original script."""
# first test scanner
test_loop_count = 0
found_macs = scan_macs()
print("Found these MACs on a scan:")
print(found_macs)
test_result = False
sensors = []
mac_list = [
"A4:C1:38:96:53:30",
"A4:C1:38:11:F4:2A",
"A4:C1:38:24:15:B0",
"A4:C1:38:D7:8D:26"
"A4:C1:38:D7:8D:26",
]
print("Starting BLE test...")
@@ -97,17 +233,22 @@ def test():
print(f" {mac}")
test_list = mac_list
while not test_result:
print(" Test loop start. Initializing run...")
print(f" Test loop {test_loop_count} start. Initializing run...")
test_run_results = sensor_data_query(test_list)
test_loop_count = test_loop_count + 1
for test_run_result in test_run_results:
sensors.append(test_run_result)
passed_list = [device['name'] for device in sensors]
passed_list = [device["name"] for device in sensors]
print(f" Passed list - {passed_list}")
if len(mac_list) == len(passed_list):
print(" Test run passed, yee-haw")
print(f" Test run {test_count} completed, yee-haw")
for sensor in sensors:
print(f" {sensor['name']} - Temp: {round(sensor['temp'],1)} | Humidity: {sensor['humidity']}")
test_result = True
else:
print(f" Test run incomplete/failed, re-running, updating test list")
print(f" Test run incomplete/failed, re-running, loop {test_loop_count}. Updating test list")
for sensor in sensors:
print(f" {sensor['name']} - Temp: {round(sensor['temp'],1)} | Humidity: {sensor['humidity']}")
mac_list_new = []
for mac in mac_list:
if mac not in passed_list:
@@ -115,10 +256,17 @@ def test():
test_list = mac_list_new
print(f" Re-do list - {test_list}")
# --------------------------------------------------------------------------- #
# If run as a script, execute the legacy test
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
#print("Welp, this will keep going forever. Have fun :)")
#while True:
#try:
test()
#except Exception as e:
# print(f"Test failed brah - {e}")
test_count = 0
print("Welp, this will keep going forever. Have fun :)")
while True:
print(f"Starting test run {test_count}")
test_count = test_count + 1
try:
test(test_count)
except Exception as e:
print(f"Test failed brah - {e}")
+1 -1
View File
@@ -4,7 +4,7 @@ MAC_PREFIX = "A4:C1:38"
SCAN_INTERVAL_MINUTES = 1
debug_output = False
bind_ip = "0.0.0.0"
api_port = 5000
api_port = 5001
redis_port = 6379
enable_redis = True
+173
View File
@@ -0,0 +1,173 @@
"""
ble_refactor.py – a drop‑in replacement for your old ble.py + scan_macs / update_cache logic.
Key points:
* One global asyncio event loop that lives for the life of the process.
* `update_cache()` can be called from any thread (e.g. a Flask background job).
* Thread‑safe read‑access via `get_sensor_summary()` / `get_sensor_details()`.
* Optional HCI reset on read timeout (cheap, no USB re‑plug).
"""
import asyncio
import logging
import subprocess
import threading
from datetime import datetime, timezone
from typing import Dict, List, Tuple
from bleak import BleakClient, BleakScanner, BleakError
# --------------------------------------------------------------------------- #
# CONFIG
# --------------------------------------------------------------------------- #
SCAN_TIMEOUT: float = 5.0 # seconds per scan
READ_TIMEOUT: float = 10.0 # seconds per GATT read
MAX_RETRIES: int = 3
# Mi sensor UUIDs (replace with yours if needed)
MI_SERVICE_UUID: str = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
TEMP_CHAR_UUID: str = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
# --------------------------------------------------------------------------- #
# STATE & LOCK
# --------------------------------------------------------------------------- #
_mac_cache: set[str] = set() # all known MACs
_sensors_cache: Dict[str, Dict] = {} # mac → {temp: float, humidity: float}
_lock = threading.Lock() # protects the two dicts above
# --------------------------------------------------------------------------- #
# GLOBAL EVENT LOOP (one per process)
# --------------------------------------------------------------------------- #
_loop = asyncio.new_event_loop()
def _start_loop() -> None:
asyncio.set_event_loop(_loop)
_loop.run_forever()
# Start the loop in a background daemon thread
_loop_thread = threading.Thread(target=_start_loop, daemon=True, name="ble-loop")
_loop_thread.start()
# Helper: submit a coroutine to the background loop
def _run_coroutine(coro):
return asyncio.run_coroutine_threadsafe(coro, _loop)
# --------------------------------------------------------------------------- #
# CORE FUNCTIONS
# --------------------------------------------------------------------------- #
async def _scan_once() -> List[str]:
"""Return a list of MAC addresses that advertise the Mi sensor name."""
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
return [dev.address for dev in devices if dev.name == "LYWSD03MMC"]
async def _read_sensor(mac: str) -> Tuple[float, int]:
"""Read temp (°C) and humidity (%) from one sensor."""
for attempt in range(1, MAX_RETRIES + 1):
try:
async with BleakClient(mac, timeout=READ_TIMEOUT) as client:
await client.wait_for_connected()
# Grab the Mi service – if missing, treat as failure
services = await client.get_services()
if MI_SERVICE_UUID not in [s.uuid for s in services]:
raise BleakError("MI service not found")
data = await client.read_gatt_char(TEMP_CHAR_UUID)
# Same parsing logic as your JS code
sign = data[1] & (1 << 7)
temp = ((data[1] & 0x7F) << 8 | data[0])
if sign:
temp -= 32767
temp = temp / 100
hum = data[2]
return temp, hum
except (asyncio.TimeoutError, BleakError) as exc:
logging.warning(f"[{mac}] attempt {attempt}/{MAX_RETRIES} failed: {exc}")
if attempt == MAX_RETRIES:
# Cheap HCI reset – often clears the “stuck” state
logging.info(f"Resetting HCI to recover from stall on {mac}")
subprocess.run(["sudo", "hciconfig", "hci0", "reset"], check=False)
raise
await asyncio.sleep(1) # back‑off before retry
raise RuntimeError(f"Unreachable code – read sensor {mac}") # pragma: no cover
async def _update_cache() -> None:
"""High‑level: scan → read all sensors → update in‑memory cache."""
try:
new_macs = await _scan_once()
except Exception as exc:
logging.error(f"Scan failed: {exc}")
return
sensor_results: List[Tuple[str, Tuple[float, int]]] = []
for mac in new_macs:
try:
temp, hum = await _read_sensor(mac)
sensor_results.append((mac, (temp, hum)))
except Exception as exc:
logging.error(f"Failed to read {mac}: {exc}")
with _lock:
for mac, (temp, hum) in sensor_results:
_sensors_cache[mac] = {"temp": temp, "humidity": hum}
_mac_cache.add(mac)
logging.info(f"Cache updated: {sorted(_mac_cache)}")
# --------------------------------------------------------------------------- #
# PUBLIC API (callable from Flask / background jobs)
# --------------------------------------------------------------------------- #
def update_cache() -> None:
"""
Entry point used by the scheduler / Flask thread.
Schedules the async work on the global event loop.
"""
_run_coroutine(_update_cache())
def get_sensor_summary() -> List[Dict]:
"""Return a list of sensor dicts – minimal: name, temp, humidity."""
with _lock:
return [
{"name": mac, **data} for mac, data in _sensors_cache.items()
]
def get_sensor_details(friendly_date: bool = False) -> Dict:
"""Return the full sensor dict + timestamps (converted if requested)."""
def ts_to_pst(ts: float) -> str:
utc = datetime.fromtimestamp(ts, tz=timezone.utc)
pacific = utc.astimezone(datetime.now().astimezone().tzinfo) # use local tz
return pacific.strftime("%Y-%m-%d %H:%M:%S")
with _lock:
sensors = []
for mac, data in _sensors_cache.items():
# Here you can add `first_seen`/`last_seen` if you keep timestamps elsewhere
sensors.append({"name": mac, **data})
return {"sensors": sensors}
# --------------------------------------------------------------------------- #
# OPTIONAL: HCI RESET ON START
# --------------------------------------------------------------------------- #
def reset_hci_on_start() -> None:
"""Call this once during app init if you want to be extra safe."""
subprocess.run(["sudo", "hciconfig", "hci0", "reset"], check=False)
# --------------------------------------------------------------------------- #
# MAIN (for quick manual testing)
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
reset_hci_on_start()
logging.info("Starting periodic update (every 30s)…")
while True:
update_cache()
time.sleep(30)
+28 -30
View File
@@ -4,6 +4,7 @@ import time
import redis
import pickle
import os
import subprocess
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # Python 3.9+
from bleak import BleakScanner, BleakClient
@@ -11,7 +12,7 @@ from typing import Set, Iterable
from config import *
from sensors import *
from ble import *
from ble_redis import *
# In-memory cache + thread-safe lock
_mac_cache: Set[str] = set()
@@ -78,24 +79,8 @@ def update_redis(data):
print(json.dumps(data, indent=2))
return True
# this function filters MACs
def _is_match(mac: str, name: str) -> bool:
"""Return True if the MAC starts with the configured prefix and is correct model."""
if name == "LYWSD03MMC":
return mac.upper().startswith(MAC_PREFIX.upper())
# this function scans for MACs
async def _scan_once() -> Iterable[str]:
"""Do one scan cycle and return the matching MACs."""
devices = await BleakScanner.discover(timeout=5.0) # 5s scan
return [dev.address for dev in devices if _is_match(dev.address, dev.name)]
# this very important function updates the MAC cache and the sensor objects
def update_cache() -> None:
"""
Called by the scheduler (in a separate thread). It runs a tiny
asyncio event loop to call the Bleak scan, then updates the cache.
"""
# Run the async scan in a fresh loop; Bleak requires this.
# Gets the MACs
print("")
@@ -122,19 +107,18 @@ def update_cache() -> None:
update_redis(get_sensor_details())
save_state()
# this helper function scans for MACs
def scan_macs():
print("Scanning for MACs:")
new_macs = asyncio.run(_scan_once())
with _lock:
for new_mac in new_macs:
print(f"{new_mac}")
new_sensor = Sensor(name=f"{new_mac}")
if new_mac not in set(_mac_cache):
_sensors_cache.append(new_sensor)
_mac_cache.update(new_macs)
print(f"Found {len(new_macs)} new MACs, total {len(_mac_cache)} MACs in cache.")
return new_macs
#def scan_macs():
# print("Scanning for MACs:")
# new_macs = asyncio.run(_scan_once())
# with _lock:
# for new_mac in new_macs:
# print(f"{new_mac}")
# new_sensor = Sensor(name=f"{new_mac}")
# if new_mac not in set(_mac_cache):
# _sensors_cache.append(new_sensor)
# _mac_cache.update(new_macs)
# print(f"Found {len(new_macs)} new MACs, total {len(_mac_cache)} MACs in cache.")
# return new_macs
# return just the MACs
def get_cache_snapshot() -> Set[str]:
@@ -188,3 +172,17 @@ def ts_to_pst_string(ts_str):
utc_dt = datetime.fromtimestamp(ts_str, tz=timezone.utc)
pacific_dt = utc_dt.astimezone(ZoneInfo("America/Los_Angeles"))
return pacific_dt.strftime("%Y-%m-%d %H:%M:%S")
# subroutine to run a command, return stdout as array unless zero_only then return [0]
def run_command(cmd, zero_only=False, use_shell=True, req_check = True):
# Run the command and capture the output
result = subprocess.run(cmd, shell=use_shell, check=req_check, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Decode the byte output to a string
output = result.stdout.decode('utf-8')
# Split the output into lines and store it in an array
output_lines = [line for line in output.split('\n') if line]
# Return result
try:
return output_lines[0] if zero_only else output_lines
except:
return output_lines
+74 -33
View File
@@ -1,33 +1,35 @@
<?php
declare(strict_types=1);
# ---------- Settings ----------
date_default_timezone_set('America/Los_Angeles');
/* ---------- Settings ----------
* Adjust if your API lives somewhere else.
*/
$api_host = 'http://172.20.20.51';
$api_port = 5001; // drive‑health API
$api_path = '/details'; // endpoint that returns the JSON you showed
# ---------- Helper: JSON fetch ----------
/* ---------- Helper: JSON fetch ----------
*/
function fetch_json(string $url): ?array
{
$ctx = stream_context_create([
'http' => ['timeout' => 2]
]);
$ctx = stream_context_create(['http' => ['timeout' => 2]]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
return ($raw !== false) ? json_decode($raw, true) : null;
}
/* ---------- Helper: format epoch to Pacific time ----------
*/
function fmt_ts(int $epoch): string
{
$dt = new DateTime("@$epoch");
$dt->setTimezone(new DateTimeZone('America/Los_Angeles'));
// 1:30:30 pm 10-14-2026
return $dt->format('g:i:s a m-d-Y');
}
# ---------- Load data ----------
$api_url = "$api_host:$api_port$api_path";
$payload = fetch_json($api_url);
foreach ($payload['sensors'] as $s){
echo $s;
}
# ---------- Render ----------
/* ---------- Load data ----------
*/
$api_url = "$api_host:$api_port$api_path";
$payload = fetch_json($api_url);
?>
<!doctype html>
<html lang="en">
@@ -36,43 +38,66 @@ foreach ($payload['sensors'] as $s){
<title>Sensor Dashboard</title>
<link rel="stylesheet" href="src/styles.css">
<style>
.card { max-width: 550px;}
.card { max-width: 750px; }
</style>
</head>
<body>
<div class="card">
<center>
<h1>Sensor Dashboard</h1>
<?php if ($payload === null): ?>
<p style="color:red;">⚠️ Could not fetch data from the API.</p>
<?php else: ?>
<table id="sensor-table">
<table id="sensor-table">
<thead>
<tr>
<th>Device</th>
<th>Comment</th>
<th>Sensor Time</th>
<th>Temp (°C)</th>
<th>Humidity %</th>
<?php //<th>Humidity %</th> ?>
</tr>
</thead>
<tbody>
<?php foreach ($payload['sensors'] as $s): ?>
<?php
$temp = $hum = '';
foreach ($s['data']['data'] as $d) {
if ($d['name'] === 'temp') $temp = $d['value'];
if ($d['name'] === 'humidity') $hum = $d['value'];
// sensor‑level timestamps
$sensorFirst = fmt_ts($s['first_seen']);
$sensorLast = fmt_ts($s['last_seen']);
// value‑level timestamps
$temp = $hum = '';
$tFirst = $tLast = $hFirst = $hLast = '';
foreach ($s['data'] as $d) {
if ($d['name'] === 'temp') {
$temp = $d['value'] ;
$tFirst = fmt_ts($d['first_seen']);
$tLast = fmt_ts($d['last_seen']);
} elseif ($d['name'] === 'humidity') {
$hum = $d['value'];
$hFirst = fmt_ts($d['first_seen']);
$hLast = fmt_ts($d['last_seen']);
}
}
// cast values to string for htmlspecialchars
$tempStr = htmlspecialchars((string)$temp);
$tempF = (intval($tempStr) * 1.8) + 32;
$humStr = htmlspecialchars((string)$hum);
$comment = htmlspecialchars($s['comment'] ?? '');
?>
<tr>
<td><?= htmlspecialchars($s['name']) ?></td>
<td><?= htmlspecialchars($s['comment'] ?? '') ?></td>
<td><?= htmlspecialchars($temp) ?></td>
<td><?= htmlspecialchars($hum) ?></td>
<td><?= $comment ?></td>
<td><small>First seen:<br><?= $sensorFirst ?><br>Last seen:<br> <?= $sensorLast ?></small></td>
<td><?= $tempF.'°F' ?><br><small>First seen: <?= $tFirst ?><br>Last seen: <?= $tLast ?></small></td>
<?php /*<td><?= $humStr.'%' ?><br><small>First seen: <?= $hFirst ?><br>Last seen: <?= $hLast ?></small></td>*/ ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</center>
<?php endif; ?>
</div>
<!-- Socket.io client -->
@@ -82,17 +107,33 @@ foreach ($payload['sensors'] as $s){
// Receive updated rows from Redis
socket.on('temp_hum', data => {
// `data` has the same structure as the API response
const tbody = document.querySelector('#sensor-table tbody');
tbody.innerHTML = ''; // clear existing rows
tbody.innerHTML = '';
data.sensors.forEach(s => {
const temp = s.data.data.find(d => d.name==='temp')?.value ?? '';
const hum = s.data.data.find(d => d.name==='humidity')?.value ?? '';
const temp = s.data.find(d => d.name==='temp')?.value ?? '';
const hum = s.data.find(d => d.name==='humidity')?.value ?? '';
const row = document.createElement('tr');
row.innerHTML = `<td>${s.name}</td><td>${s.comment}</td><td>${temp}</td><td>${hum}</td>`;
row.innerHTML = `
<td>${s.name}</td>
<td>${s.comment}</td>
<td>${fmt_ts(s.first_seen)}<br>Last: ${fmt_ts(s.last_seen)}</td>
<td>${temp}<br>First: ${s.data.find(d=>d.name==='temp')?.first_seen ?? ''}<br>Last: ${s.data.find(d=>d.name==='temp')?.last_seen ?? ''}</td>
<td>${hum}<br>First: ${s.data.find(d=>d.name==='humidity')?.first_seen ?? ''}<br>Last: ${s.data.find(d=>d.name==='humidity')?.last_seen ?? ''}</td>
`;
tbody.appendChild(row);
});
});
// Helper for JS timestamp formatting (same as PHP)
function fmt_ts(epoch) {
const dt = new Date(epoch * 1000);
const options = { hour12: true, hour: 'numeric', minute: '2-digit', second: '2-digit', month: '2-digit', day: '2-digit', year: 'numeric' };
const formatter = new Intl.DateTimeFormat('en-US', options);
// Convert to Pacific time by applying offset -7/ -8 depending on DST
const pacificOffset = (dt.getTimezoneOffset() + (new Date().getTimezoneOffset() - 0)) * 60 * 1000;
const pacific = new Date(dt.getTime() + pacificOffset);
return pacific.toLocaleString('en-US', options).replace(',', '');
}
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/* ---------- Settings ---------- */
date_default_timezone_set('America/Los_Angeles');
$api_host = 'http://172.20.20.51';
$api_port = 5001; // drive‑health API
$api_path = '/details'; // endpoint that returns the JSON you showed
/* ---------- Helper: JSON fetch ---------- */
function fetch_json(string $url): ?array
{
$ctx = stream_context_create(['http' => ['timeout' => 2]]);
$raw = @file_get_contents($url, false, $ctx);
return ($raw !== false) ? json_decode($raw, true) : null;
}
/* ---------- Load data ---------- */
$api_url = "$api_host:$api_port$api_path";
$payload = fetch_json($api_url);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sensor Dashboard</title>
<link rel="stylesheet" href="src/styles.css">
<style>
.card { max-width: 550px; }
</style>
</head>
<body>
<div class="card">
<h1>Sensor Dashboard</h1>
<?php if ($payload === null): ?>
<p style="color:red;">Could not fetch data from the API.</p>
<?php else: ?>
<table id="sensor-table">
<thead>
<tr>
<th>Device</th>
<th>Comment</th>
<th>Temp (°C)</th>
<th>Humidity %</th>
</tr>
</thead>
<tbody>
<?php foreach ($payload['sensors'] as $s): ?>
<?php
// In the new format, $s['data'] is already an array of readings
$temp = $hum = '';
foreach ($s['data'] as $d) {
if ($d['name'] === 'temp') $temp = $d['value'];
if ($d['name'] === 'humidity')$hum = $d['value'];
}
$tempStr = htmlspecialchars((string)$temp);
$humStr = htmlspecialchars((string)$hum);
?>
<tr>
<td><?= htmlspecialchars($s['name']) ?></td>
<td><?= htmlspecialchars($s['comment'] ?? '') ?></td>
<td><?= htmlspecialchars($temp) ?></td>
<td><?= htmlspecialchars($hum) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Socket.io client -->
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
// Receive updated rows from Redis
socket.on('temp_hum', data => {
// `data` has the same structure as the API response
const tbody = document.querySelector('#sensor-table tbody');
tbody.innerHTML = ''; // clear existing rows
data.sensors.forEach(s => {
const temp = s.data.find(d => d.name==='temp')?.value ?? '';
const hum = s.data.find(d => d.name==='humidity')?.value ?? '';
const row = document.createElement('tr');
row.innerHTML = `<td>${s.name}</td><td>${s.comment}</td><td>${temp}</td><td>${hum}</td>`;
tbody.appendChild(row);
});
});
</script>
</body>
</html>