From 0d975a15a8d2a550b590d679d5b270d52f611255 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 14 Oct 2026 04:49:43 -0700 Subject: [PATCH] overhaul ble.py, move all async calls there --- api/app.py | 6 +- api/ble-old.py | 124 +++++++++++++++ api/ble.py | 286 ++++++++++++++++++++++++++-------- api/config.py | 2 +- api/scanner-new.py | 173 ++++++++++++++++++++ api/scanner.py | 58 ++++--- web_dash/web/html/index.php | 107 +++++++++---- web_dash/web/html/scratch.php | 91 +++++++++++ 8 files changed, 712 insertions(+), 135 deletions(-) create mode 100644 api/ble-old.py create mode 100644 api/scanner-new.py create mode 100644 web_dash/web/html/scratch.php diff --git a/api/app.py b/api/app.py index 2ae86fc..7994364 100644 --- a/api/app.py +++ b/api/app.py @@ -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 ------------------------------------------------------------- diff --git a/api/ble-old.py b/api/ble-old.py new file mode 100644 index 0000000..bea7f80 --- /dev/null +++ b/api/ble-old.py @@ -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}") diff --git a/api/ble.py b/api/ble.py index bea7f80..8e44a74 100644 --- a/api/ble.py +++ b/api/ble.py @@ -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}") - - # Read temperature data - sensor_data = await client.read_gatt_char(TEMP_CHAR_UUID) + print(f"Discovery failed - {e}") + # --------------------------------------------------------------- - # 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] + # 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 - print(f"-- Temperature: {temp:.1f}°C, Humidity: {hum}%") - return temp, hum + 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) -def sensor_data_query(mac_list): + 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 + + +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}") \ No newline at end of file diff --git a/api/config.py b/api/config.py index b5fa8fb..0df4dce 100644 --- a/api/config.py +++ b/api/config.py @@ -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 diff --git a/api/scanner-new.py b/api/scanner-new.py new file mode 100644 index 0000000..b7197a3 --- /dev/null +++ b/api/scanner-new.py @@ -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) \ No newline at end of file diff --git a/api/scanner.py b/api/scanner.py index 94a6660..d381ac4 100644 --- a/api/scanner.py +++ b/api/scanner.py @@ -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 \ No newline at end of file diff --git a/web_dash/web/html/index.php b/web_dash/web/html/index.php index 3350f38..96b42fd 100644 --- a/web_dash/web/html/index.php +++ b/web_dash/web/html/index.php @@ -1,33 +1,35 @@ ['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); ?> @@ -36,43 +38,66 @@ foreach ($payload['sensors'] as $s){ Sensor Dashboard
+

Sensor Dashboard

⚠️ Could not fetch data from the API.

- +
+ - + Humidity % ?> - - - + + + +
First seen:
Last seen:
*/ ?>
Device CommentSensor Time Temp (°C)Humidity %
First seen:

Last seen:

First seen:
Last seen:
+
@@ -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 = `${s.name}${s.comment}${temp}${hum}`; + row.innerHTML = ` + ${s.name} + ${s.comment} + ${fmt_ts(s.first_seen)}
Last: ${fmt_ts(s.last_seen)} + ${temp}
First: ${s.data.find(d=>d.name==='temp')?.first_seen ?? ''}
Last: ${s.data.find(d=>d.name==='temp')?.last_seen ?? ''} + ${hum}
First: ${s.data.find(d=>d.name==='humidity')?.first_seen ?? ''}
Last: ${s.data.find(d=>d.name==='humidity')?.last_seen ?? ''} + `; 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(',', ''); + } \ No newline at end of file diff --git a/web_dash/web/html/scratch.php b/web_dash/web/html/scratch.php new file mode 100644 index 0000000..c78e6dc --- /dev/null +++ b/web_dash/web/html/scratch.php @@ -0,0 +1,91 @@ + ['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); +?> + + + + +Sensor Dashboard + + + + +
+

Sensor Dashboard

+ + +

Could not fetch data from the API.

+ + + + + + + + + + + + + + + + + + + + + +
DeviceCommentTemp (°C)Humidity %
+ +
+ + + + + \ No newline at end of file