overhaul ble.py, move all async calls there
This commit is contained in:
1 parent
87cd24234a
commit
0d975a15a8
8 files changed
+712
-135
No files matched your search
+4
-2
@@ -5,7 +5,6 @@ from flask_apscheduler import APScheduler
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from scanner import *
|
from scanner import *
|
||||||
from config import *
|
from config import *
|
||||||
from ble_redis import *
|
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger('apscheduler').setLevel(logging.ERROR)
|
logging.getLogger('apscheduler').setLevel(logging.ERROR)
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ app = Flask(__name__)
|
|||||||
scheduler = APScheduler()
|
scheduler = APScheduler()
|
||||||
scheduler.init_app(app)
|
scheduler.init_app(app)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
run_counter = 0
|
||||||
|
|
||||||
scan_macs()
|
scan_macs()
|
||||||
# --- Scheduler job ----------------------------------------------------
|
# --- Scheduler job ----------------------------------------------------
|
||||||
@@ -25,8 +24,11 @@ scan_macs()
|
|||||||
misfire_grace_time=30
|
misfire_grace_time=30
|
||||||
)
|
)
|
||||||
def ble_scan_job():
|
def ble_scan_job():
|
||||||
|
run_counter = run_counter + 1
|
||||||
"""Runs in the background, calls the async scanner."""
|
"""Runs in the background, calls the async scanner."""
|
||||||
update_cache()
|
update_cache()
|
||||||
|
#if run_counter > 9:
|
||||||
|
# run_command
|
||||||
|
|
||||||
|
|
||||||
# --- API -------------------------------------------------------------
|
# --- API -------------------------------------------------------------
|
||||||
|
|||||||
+124
@@ -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}")
|
||||||
+217
-69
@@ -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
|
import asyncio
|
||||||
from bleak import BleakClient, BleakScanner
|
|
||||||
import warnings, logging
|
import warnings, logging
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
logging.getLogger('apscheduler').setLevel(logging.ERROR)
|
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"
|
MI_SERVICE_UUID = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
|
||||||
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
|
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
|
||||||
|
|
||||||
# scanner constants
|
SCAN_TIMEOUT = 5.0 # seconds to scan for each MAC
|
||||||
full_timeout = 60
|
CONNECT_TIMEOUT = 10.0
|
||||||
attempts = 6
|
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
|
# Data model
|
||||||
fail_count = full_timeout / ratio
|
# --------------------------------------------------------------------------- #
|
||||||
device = None
|
@dataclass
|
||||||
print(f"Beginning read_data for {mac_address} - {full_timeout} timeout - {ratio} ratio - please stand by...")
|
class SensorReading:
|
||||||
while fail_count > 0 and not device:
|
"""Single Mi sensor reading."""
|
||||||
# print(f"Starting discover loop {fail_count}")
|
mac: str
|
||||||
devices = await BleakScanner.discover(timeout=ratio)
|
temperature: float
|
||||||
# print("BleakScanner discover timeout pass")
|
humidity: int
|
||||||
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")
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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
|
return None
|
||||||
|
|
||||||
# Connect to the device
|
try:
|
||||||
mi_service = None
|
async with BleakClient(address, timeout=CONNECT_TIMEOUT) as client:
|
||||||
async with BleakClient(device) as client:
|
# <--- NEW BLOCK ----------------------------------------------
|
||||||
|
|
||||||
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:
|
try:
|
||||||
#print(".. Client connected, getting services")
|
await asyncio.wait_for(client.get_services(),
|
||||||
services = client.services
|
timeout=SERVICE_DISCOVERY_TIMEOUT)
|
||||||
#print(".. Services acquired, loading uuid matches")
|
except asyncio.TimeoutError:
|
||||||
mi_service = next((s for s in services if s.uuid.lower() == MI_SERVICE_UUID.lower()), None)
|
log.warning("Service discovery timed out for %s", mac)
|
||||||
success = True
|
return None
|
||||||
#print("uuid matches loaded")
|
|
||||||
except Exception as e:
|
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)
|
|
||||||
|
|
||||||
# Parse the temperature value (same logic as in JavaScript)
|
# If we get here, services are available.
|
||||||
sign = sensor_data[1] & (1 << 7)
|
if MI_SERVICE_UUID.lower() not in (
|
||||||
temp = ((sensor_data[1] & 0x7F) << 8 | sensor_data[0])
|
s.uuid.lower() for s in client.services
|
||||||
if sign:
|
):
|
||||||
temp = temp - 32767
|
log.error("Mi service not found on %s", mac)
|
||||||
temp = temp / 100
|
return None
|
||||||
hum = sensor_data[2]
|
|
||||||
|
|
||||||
print(f"-- Temperature: {temp:.1f}°C, Humidity: {hum}%")
|
raw = await asyncio.wait_for(
|
||||||
return temp, hum
|
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 = []
|
result = []
|
||||||
for mac in mac_list:
|
for mac in mac_list:
|
||||||
try:
|
try:
|
||||||
this_result = asyncio.run(read_data(mac))
|
this_result = asyncio.run(read_data(mac))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading BLE data - {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")
|
print(f"Data query complete - returning result")
|
||||||
return 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
|
test_result = False
|
||||||
sensors = []
|
sensors = []
|
||||||
mac_list = [
|
mac_list = [
|
||||||
|
"A4:C1:38:96:53:30",
|
||||||
"A4:C1:38:11:F4:2A",
|
"A4:C1:38:11:F4:2A",
|
||||||
"A4:C1:38:24:15:B0",
|
"A4:C1:38:24:15:B0",
|
||||||
"A4:C1:38:D7:8D:26"
|
"A4:C1:38:D7:8D:26",
|
||||||
]
|
]
|
||||||
|
|
||||||
print("Starting BLE test...")
|
print("Starting BLE test...")
|
||||||
@@ -97,17 +233,22 @@ def test():
|
|||||||
print(f" {mac}")
|
print(f" {mac}")
|
||||||
test_list = mac_list
|
test_list = mac_list
|
||||||
while not test_result:
|
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_run_results = sensor_data_query(test_list)
|
||||||
|
test_loop_count = test_loop_count + 1
|
||||||
for test_run_result in test_run_results:
|
for test_run_result in test_run_results:
|
||||||
sensors.append(test_run_result)
|
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}")
|
print(f" Passed list - {passed_list}")
|
||||||
if len(mac_list) == len(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
|
test_result = True
|
||||||
else:
|
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 = []
|
mac_list_new = []
|
||||||
for mac in mac_list:
|
for mac in mac_list:
|
||||||
if mac not in passed_list:
|
if mac not in passed_list:
|
||||||
@@ -115,10 +256,17 @@ def test():
|
|||||||
test_list = mac_list_new
|
test_list = mac_list_new
|
||||||
print(f" Re-do list - {test_list}")
|
print(f" Re-do list - {test_list}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# If run as a script, execute the legacy test
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
#print("Welp, this will keep going forever. Have fun :)")
|
test_count = 0
|
||||||
#while True:
|
print("Welp, this will keep going forever. Have fun :)")
|
||||||
#try:
|
while True:
|
||||||
test()
|
print(f"Starting test run {test_count}")
|
||||||
#except Exception as e:
|
test_count = test_count + 1
|
||||||
# print(f"Test failed brah - {e}")
|
try:
|
||||||
|
test(test_count)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Test failed brah - {e}")
|
||||||
+1
-1
@@ -4,7 +4,7 @@ MAC_PREFIX = "A4:C1:38"
|
|||||||
SCAN_INTERVAL_MINUTES = 1
|
SCAN_INTERVAL_MINUTES = 1
|
||||||
debug_output = False
|
debug_output = False
|
||||||
bind_ip = "0.0.0.0"
|
bind_ip = "0.0.0.0"
|
||||||
api_port = 5000
|
api_port = 5001
|
||||||
redis_port = 6379
|
redis_port = 6379
|
||||||
enable_redis = True
|
enable_redis = True
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,7 @@ import time
|
|||||||
import redis
|
import redis
|
||||||
import pickle
|
import pickle
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo # Python 3.9+
|
from zoneinfo import ZoneInfo # Python 3.9+
|
||||||
from bleak import BleakScanner, BleakClient
|
from bleak import BleakScanner, BleakClient
|
||||||
@@ -11,7 +12,7 @@ from typing import Set, Iterable
|
|||||||
from config import *
|
from config import *
|
||||||
from sensors import *
|
from sensors import *
|
||||||
from ble import *
|
from ble import *
|
||||||
from ble_redis import *
|
|
||||||
|
|
||||||
# In-memory cache + thread-safe lock
|
# In-memory cache + thread-safe lock
|
||||||
_mac_cache: Set[str] = set()
|
_mac_cache: Set[str] = set()
|
||||||
@@ -78,24 +79,8 @@ def update_redis(data):
|
|||||||
print(json.dumps(data, indent=2))
|
print(json.dumps(data, indent=2))
|
||||||
return True
|
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
|
# this very important function updates the MAC cache and the sensor objects
|
||||||
def update_cache() -> None:
|
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.
|
# Run the async scan in a fresh loop; Bleak requires this.
|
||||||
# Gets the MACs
|
# Gets the MACs
|
||||||
print("")
|
print("")
|
||||||
@@ -122,19 +107,18 @@ def update_cache() -> None:
|
|||||||
update_redis(get_sensor_details())
|
update_redis(get_sensor_details())
|
||||||
save_state()
|
save_state()
|
||||||
|
|
||||||
# this helper function scans for MACs
|
#def scan_macs():
|
||||||
def scan_macs():
|
# print("Scanning for MACs:")
|
||||||
print("Scanning for MACs:")
|
# new_macs = asyncio.run(_scan_once())
|
||||||
new_macs = asyncio.run(_scan_once())
|
# with _lock:
|
||||||
with _lock:
|
# for new_mac in new_macs:
|
||||||
for new_mac in new_macs:
|
# print(f"{new_mac}")
|
||||||
print(f"{new_mac}")
|
# new_sensor = Sensor(name=f"{new_mac}")
|
||||||
new_sensor = Sensor(name=f"{new_mac}")
|
# if new_mac not in set(_mac_cache):
|
||||||
if new_mac not in set(_mac_cache):
|
# _sensors_cache.append(new_sensor)
|
||||||
_sensors_cache.append(new_sensor)
|
# _mac_cache.update(new_macs)
|
||||||
_mac_cache.update(new_macs)
|
# print(f"Found {len(new_macs)} new MACs, total {len(_mac_cache)} MACs in cache.")
|
||||||
print(f"Found {len(new_macs)} new MACs, total {len(_mac_cache)} MACs in cache.")
|
# return new_macs
|
||||||
return new_macs
|
|
||||||
|
|
||||||
# return just the MACs
|
# return just the MACs
|
||||||
def get_cache_snapshot() -> Set[str]:
|
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)
|
utc_dt = datetime.fromtimestamp(ts_str, tz=timezone.utc)
|
||||||
pacific_dt = utc_dt.astimezone(ZoneInfo("America/Los_Angeles"))
|
pacific_dt = utc_dt.astimezone(ZoneInfo("America/Los_Angeles"))
|
||||||
return pacific_dt.strftime("%Y-%m-%d %H:%M:%S")
|
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
@@ -1,33 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
# ---------- Settings ----------
|
/* ---------- Settings ----------
|
||||||
date_default_timezone_set('America/Los_Angeles');
|
* Adjust if your API lives somewhere else.
|
||||||
|
*/
|
||||||
$api_host = 'http://172.20.20.51';
|
$api_host = 'http://172.20.20.51';
|
||||||
$api_port = 5001; // drive‑health API
|
$api_port = 5001; // drive‑health API
|
||||||
$api_path = '/details'; // endpoint that returns the JSON you showed
|
$api_path = '/details'; // endpoint that returns the JSON you showed
|
||||||
|
|
||||||
# ---------- Helper: JSON fetch ----------
|
/* ---------- Helper: JSON fetch ----------
|
||||||
|
*/
|
||||||
function fetch_json(string $url): ?array
|
function fetch_json(string $url): ?array
|
||||||
{
|
{
|
||||||
$ctx = stream_context_create([
|
$ctx = stream_context_create(['http' => ['timeout' => 2]]);
|
||||||
'http' => ['timeout' => 2]
|
|
||||||
]);
|
|
||||||
$raw = @file_get_contents($url, false, $ctx);
|
$raw = @file_get_contents($url, false, $ctx);
|
||||||
if ($raw === false) {
|
return ($raw !== false) ? json_decode($raw, true) : null;
|
||||||
return null;
|
}
|
||||||
}
|
/* ---------- Helper: format epoch to Pacific time ----------
|
||||||
$data = json_decode($raw, true);
|
*/
|
||||||
return is_array($data) ? $data : null;
|
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 ----------
|
/* ---------- Load data ----------
|
||||||
$api_url = "$api_host:$api_port$api_path";
|
*/
|
||||||
$payload = fetch_json($api_url);
|
$api_url = "$api_host:$api_port$api_path";
|
||||||
foreach ($payload['sensors'] as $s){
|
$payload = fetch_json($api_url);
|
||||||
echo $s;
|
|
||||||
}
|
|
||||||
# ---------- Render ----------
|
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -36,43 +38,66 @@ foreach ($payload['sensors'] as $s){
|
|||||||
<title>Sensor Dashboard</title>
|
<title>Sensor Dashboard</title>
|
||||||
<link rel="stylesheet" href="src/styles.css">
|
<link rel="stylesheet" href="src/styles.css">
|
||||||
<style>
|
<style>
|
||||||
.card { max-width: 550px;}
|
.card { max-width: 750px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<center>
|
||||||
<h1>Sensor Dashboard</h1>
|
<h1>Sensor Dashboard</h1>
|
||||||
|
|
||||||
<?php if ($payload === null): ?>
|
<?php if ($payload === null): ?>
|
||||||
<p style="color:red;">⚠️ Could not fetch data from the API.</p>
|
<p style="color:red;">⚠️ Could not fetch data from the API.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table id="sensor-table">
|
<table id="sensor-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Device</th>
|
<th>Device</th>
|
||||||
<th>Comment</th>
|
<th>Comment</th>
|
||||||
|
<th>Sensor Time</th>
|
||||||
<th>Temp (°C)</th>
|
<th>Temp (°C)</th>
|
||||||
<th>Humidity %</th>
|
<?php //<th>Humidity %</th> ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($payload['sensors'] as $s): ?>
|
<?php foreach ($payload['sensors'] as $s): ?>
|
||||||
<?php
|
<?php
|
||||||
$temp = $hum = '';
|
// sensor‑level timestamps
|
||||||
foreach ($s['data']['data'] as $d) {
|
$sensorFirst = fmt_ts($s['first_seen']);
|
||||||
if ($d['name'] === 'temp') $temp = $d['value'];
|
$sensorLast = fmt_ts($s['last_seen']);
|
||||||
if ($d['name'] === 'humidity') $hum = $d['value'];
|
|
||||||
|
// 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>
|
<tr>
|
||||||
<td><?= htmlspecialchars($s['name']) ?></td>
|
<td><?= htmlspecialchars($s['name']) ?></td>
|
||||||
<td><?= htmlspecialchars($s['comment'] ?? '') ?></td>
|
<td><?= $comment ?></td>
|
||||||
<td><?= htmlspecialchars($temp) ?></td>
|
<td><small>First seen:<br><?= $sensorFirst ?><br>Last seen:<br> <?= $sensorLast ?></small></td>
|
||||||
<td><?= htmlspecialchars($hum) ?></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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</center>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<!-- Socket.io client -->
|
<!-- Socket.io client -->
|
||||||
@@ -82,17 +107,33 @@ foreach ($payload['sensors'] as $s){
|
|||||||
|
|
||||||
// Receive updated rows from Redis
|
// Receive updated rows from Redis
|
||||||
socket.on('temp_hum', data => {
|
socket.on('temp_hum', data => {
|
||||||
// `data` has the same structure as the API response
|
|
||||||
const tbody = document.querySelector('#sensor-table tbody');
|
const tbody = document.querySelector('#sensor-table tbody');
|
||||||
tbody.innerHTML = ''; // clear existing rows
|
tbody.innerHTML = '';
|
||||||
data.sensors.forEach(s => {
|
data.sensors.forEach(s => {
|
||||||
const temp = s.data.data.find(d => d.name==='temp')?.value ?? '';
|
const temp = s.data.find(d => d.name==='temp')?.value ?? '';
|
||||||
const hum = s.data.data.find(d => d.name==='humidity')?.value ?? '';
|
const hum = s.data.find(d => d.name==='humidity')?.value ?? '';
|
||||||
const row = document.createElement('tr');
|
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);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user