purging old files
This commit is contained in:
1 parent
ce011bd236
commit
ac2b142b7a
9 files changed
+1
-806
No files matched your search
@@ -1,61 +0,0 @@
|
|||||||
import redis
|
|
||||||
import atexit
|
|
||||||
from flask import Flask, jsonify
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
scheduler = APScheduler()
|
|
||||||
scheduler.init_app(app)
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
|
|
||||||
scan_macs()
|
|
||||||
# --- Scheduler job ----------------------------------------------------
|
|
||||||
@scheduler.task(
|
|
||||||
'interval',
|
|
||||||
id='ble_scan',
|
|
||||||
minutes=SCAN_INTERVAL_MINUTES,
|
|
||||||
misfire_grace_time=30
|
|
||||||
)
|
|
||||||
def ble_scan_job():
|
|
||||||
"""Runs in the background, calls the async scanner."""
|
|
||||||
update_cache()
|
|
||||||
|
|
||||||
|
|
||||||
# --- API -------------------------------------------------------------
|
|
||||||
@app.route('/macs', methods=['GET'])
|
|
||||||
def list_macs():
|
|
||||||
"""Return the current set of MACs as JSON."""
|
|
||||||
macs = sorted(get_cache_snapshot())
|
|
||||||
return jsonify({"count": len(macs), "macs": macs})
|
|
||||||
|
|
||||||
@app.route('/details', methods=['GET'])
|
|
||||||
def sensor_detail():
|
|
||||||
"""Return the current set of MACs as JSON."""
|
|
||||||
sensors = get_sensor_details()
|
|
||||||
return jsonify(sensors)
|
|
||||||
|
|
||||||
@app.route('/summary', methods=['GET'])
|
|
||||||
def sensor_summary():
|
|
||||||
"""Return the current set of MACs as JSON."""
|
|
||||||
sensors = get_sensor_summary()
|
|
||||||
return jsonify(sensors)
|
|
||||||
|
|
||||||
# --- Convenience route -----------------------------------------------
|
|
||||||
@app.route('/', methods=['GET'])
|
|
||||||
def hello():
|
|
||||||
return "BLE Scanner API - see /macs for the list."
|
|
||||||
|
|
||||||
# --- Run --------------------------------------------------------------
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# The scheduler runs in its own thread; the main thread serves Flask.
|
|
||||||
app.run(host=bind_ip, port=api_port, debug=False)
|
|
||||||
# Register a graceful shutdown hook so the scanner state is persisted
|
|
||||||
atexit.register(save_state)
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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}")
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
|
|
||||||
import redis
|
|
||||||
import json
|
|
||||||
from config import *
|
|
||||||
|
|
||||||
####################################################
|
|
||||||
### Redis Functions
|
|
||||||
####################################################
|
|
||||||
r = redis.Redis(host=bind_ip, port=redis_port)
|
|
||||||
|
|
||||||
def update_redis(data):
|
|
||||||
# push data to redis
|
|
||||||
# Publish to the Redis channel that the WS server is listening on
|
|
||||||
r.publish('temp_hum', json.dumps(data))
|
|
||||||
if debug_output:
|
|
||||||
print("=== Stats Redis Update ===")
|
|
||||||
print(json.dumps(data, indent=2))
|
|
||||||
return True
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# --- BLE & app config -------------------------------------------------
|
|
||||||
|
|
||||||
MAC_PREFIX = "A4:C1:38"
|
|
||||||
SCAN_INTERVAL_MINUTES = 1
|
|
||||||
debug_output = False
|
|
||||||
bind_ip = "0.0.0.0"
|
|
||||||
api_port = 5000
|
|
||||||
redis_port = 6379
|
|
||||||
enable_redis = True
|
|
||||||
|
|
||||||
# --- This will store friendly names for MAC addresses -----------------
|
|
||||||
comment_lookup = [
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:11:F4:2A",
|
|
||||||
"comment": "Kitchen - 0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:D7:8D:26",
|
|
||||||
"comment": "Table - 1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:24:15:B0",
|
|
||||||
"comment": "Hallway 2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:96:53:30",
|
|
||||||
"comment": "Bedroom 3"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
anyio
|
|
||||||
bleak
|
|
||||||
cryptography
|
|
||||||
click
|
|
||||||
setuptools
|
|
||||||
Flask-APScheduler
|
|
||||||
Flask
|
|
||||||
APScheduler
|
|
||||||
redis
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
# 3. Create a venv (optional but recommended)
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# 4. Install dependencies
|
|
||||||
pip install -r requirements.txt --quiet
|
|
||||||
|
|
||||||
# 5. Start
|
|
||||||
#python ble.py
|
|
||||||
python app.py
|
|
||||||
-189
@@ -1,189 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import redis
|
|
||||||
import pickle
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from zoneinfo import ZoneInfo # Python 3.9+
|
|
||||||
from bleak import BleakScanner, BleakClient
|
|
||||||
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()
|
|
||||||
_lock = threading.Lock()
|
|
||||||
_sensors_cache = []
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
# Pickle persistence helpers
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
STATE_FILE = "scanner_state.pkl"
|
|
||||||
|
|
||||||
def save_state() -> None:
|
|
||||||
"""
|
|
||||||
Persist the MAC cache and sensor objects to disk.
|
|
||||||
Called automatically after each cache refresh and at program exit.
|
|
||||||
"""
|
|
||||||
with _lock:
|
|
||||||
state = {
|
|
||||||
"mac_cache": _mac_cache,
|
|
||||||
"sensors": _sensors_cache,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with open(STATE_FILE, "wb") as f:
|
|
||||||
pickle.dump(state, f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
||||||
print("[Scanner] State persisted to", STATE_FILE)
|
|
||||||
except Exception as exc:
|
|
||||||
# Don't crash the app – just log the error
|
|
||||||
print("[Scanner] Warning: could not persist state:", exc)
|
|
||||||
|
|
||||||
def load_state() -> None:
|
|
||||||
"""
|
|
||||||
Load the MAC cache and sensor objects from disk if the file exists.
|
|
||||||
Called automatically on import.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(STATE_FILE):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(STATE_FILE, "rb") as f:
|
|
||||||
state = pickle.load(f)
|
|
||||||
with _lock:
|
|
||||||
# NOTE: we replace the lists/sets to avoid mutating old references
|
|
||||||
_mac_cache.clear()
|
|
||||||
_mac_cache.update(state.get("mac_cache", []))
|
|
||||||
global _sensors_cache
|
|
||||||
_sensors_cache = state.get("sensors", [])
|
|
||||||
print("[Scanner] Loaded state from", STATE_FILE)
|
|
||||||
for cached_mac in _mac_cache:
|
|
||||||
print(f"Found {cached_mac}")
|
|
||||||
except Exception as exc:
|
|
||||||
print("[Scanner] Warning: could not load state:", exc)
|
|
||||||
|
|
||||||
# Load any persisted state on module import
|
|
||||||
load_state()
|
|
||||||
|
|
||||||
# Redis Functions
|
|
||||||
r = redis.Redis(host=bind_ip, port=redis_port)
|
|
||||||
|
|
||||||
def update_redis(data):
|
|
||||||
# push data to redis
|
|
||||||
# Publish to the Redis channel that the WS server is listening on
|
|
||||||
r.publish('temp_hum', json.dumps(data))
|
|
||||||
if debug_output:
|
|
||||||
print("=== Stats Redis Update ===")
|
|
||||||
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("")
|
|
||||||
print("--- Starting update_cache loop ---")
|
|
||||||
new_macs = scan_macs()
|
|
||||||
# now that we have MACs, let's get sensor data
|
|
||||||
print("Updating sensor data...")
|
|
||||||
sensor_data = None
|
|
||||||
try:
|
|
||||||
sensor_data = sensor_data_query(new_macs)
|
|
||||||
# print(sensor_data)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Aww fuck, no data :( - {e}")
|
|
||||||
# now we update the data in the sensor objects
|
|
||||||
print("Sensor data collected:")
|
|
||||||
for sensor_datum in sensor_data:
|
|
||||||
print(f"MAC - {sensor_datum['name']} | temp - {sensor_datum['temp']} | humidity - {sensor_datum['humidity']}")
|
|
||||||
for sensor in _sensors_cache:
|
|
||||||
if sensor.name == sensor_datum['name']:
|
|
||||||
sensor.add_or_update(key="temp", value=sensor_datum['temp'])
|
|
||||||
sensor.add_or_update(key="humidity", value=sensor_datum['humidity'])
|
|
||||||
# if redis is enabled, push full details to the redis server
|
|
||||||
if enable_redis:
|
|
||||||
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
|
|
||||||
|
|
||||||
# return just the MACs
|
|
||||||
def get_cache_snapshot() -> Set[str]:
|
|
||||||
"""Return a thread-safe snapshot of the current cache."""
|
|
||||||
with _lock:
|
|
||||||
return set(_mac_cache)
|
|
||||||
|
|
||||||
# return sensor data and timestamps, can return int or string
|
|
||||||
def get_sensor_details(friendly_date = False):
|
|
||||||
sensor_list = []
|
|
||||||
for sensor in _sensors_cache:
|
|
||||||
first_seen = sensor.first_seen
|
|
||||||
last_seen = sensor.last_seen
|
|
||||||
if friendly_date:
|
|
||||||
first_seen = ts_to_pst_string(sensor.first_seen)
|
|
||||||
last_seen = ts_to_pst_string(sensor.last_seen)
|
|
||||||
# print(f"Summary - {sensor.name}")
|
|
||||||
new_sensor = {
|
|
||||||
"name": sensor.name,
|
|
||||||
"first_seen": first_seen,
|
|
||||||
"last_seen": last_seen,
|
|
||||||
"comment": sensor.comment,
|
|
||||||
"data": sensor.to_dict()
|
|
||||||
|
|
||||||
}
|
|
||||||
sensor_list.append(new_sensor)
|
|
||||||
result = {
|
|
||||||
"sensors": sensor_list
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
# return just sensor data without timestamps
|
|
||||||
def get_sensor_summary():
|
|
||||||
sensor_list = []
|
|
||||||
for sensor in _sensors_cache:
|
|
||||||
# print(f"Summary - {sensor.name}")
|
|
||||||
new_sensor = {
|
|
||||||
"name": sensor.name,
|
|
||||||
"comment": sensor.comment,
|
|
||||||
"data": sensor.to_dict(simple_return=True)
|
|
||||||
}
|
|
||||||
sensor_list.append(new_sensor)
|
|
||||||
result = {
|
|
||||||
"sensors": sensor_list
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
# helper function for ts int to string
|
|
||||||
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")
|
|
||||||
-207
@@ -1,207 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re, redis
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
from config import *
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Helper utilities
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def _current_timestamp() -> int:
|
|
||||||
"""Return the current UTC timestamp (seconds)."""
|
|
||||||
return int(datetime.now(timezone.utc).timestamp())
|
|
||||||
|
|
||||||
def _ensure_timestamp(ts: Optional[Union[int, datetime]]) -> int:
|
|
||||||
"""
|
|
||||||
Normalise a timestamp that can be an int (Unix seconds) or a datetime.
|
|
||||||
Raises ValueError if the value is not recognised.
|
|
||||||
"""
|
|
||||||
if ts is None:
|
|
||||||
return _current_timestamp()
|
|
||||||
if isinstance(ts, int):
|
|
||||||
return ts
|
|
||||||
if isinstance(ts, datetime):
|
|
||||||
return int(ts.replace(tzinfo=timezone.utc).timestamp())
|
|
||||||
raise ValueError(f"Unsupported timestamp type: {type(ts)}")
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Sensor implementation
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Sensor:
|
|
||||||
"""
|
|
||||||
Represents a sensor that records timestamped values under a set of keys.
|
|
||||||
|
|
||||||
Attributes
|
|
||||||
----------
|
|
||||||
name : str
|
|
||||||
The unique identifier for the sensor itself.
|
|
||||||
first_seen : int
|
|
||||||
Unix timestamp of when the sensor was first instantiated.
|
|
||||||
last_seen : int
|
|
||||||
Unix timestamp of the most recent data update (or creation time).
|
|
||||||
data : List[Dict[str, Any]]
|
|
||||||
A list of data points, each with keys:
|
|
||||||
- name (str)
|
|
||||||
- value (Any)
|
|
||||||
- first_seen (int)
|
|
||||||
- last_seen (int)
|
|
||||||
"""
|
|
||||||
name: str
|
|
||||||
first_seen: int = field(init=False)
|
|
||||||
last_seen: int = field(init=False)
|
|
||||||
data: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
# Validity rules
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
# Current rule: key must be a "word" (letters, digits, underscore)
|
|
||||||
VALID_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
|
||||||
|
|
||||||
def _is_valid_key(self, key: str) -> bool:
|
|
||||||
"""Return True if the key meets current validity criteria."""
|
|
||||||
is_valid = False
|
|
||||||
if bool(self.VALID_NAME_RE.match(key)):
|
|
||||||
is_valid = True
|
|
||||||
return is_valid
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
# Life-cycle hooks
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
"""Initialise timestamps when the object is created."""
|
|
||||||
# print(f"Instantiating new sensor object - {self.name}")
|
|
||||||
ts = _current_timestamp()
|
|
||||||
self.first_seen = ts
|
|
||||||
self.last_seen = ts
|
|
||||||
self.comment = self.name
|
|
||||||
for sensor in comment_lookup:
|
|
||||||
if sensor['mac'] == self.name:
|
|
||||||
self.comment = sensor['comment']
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
# Core API
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def add_or_update(self,
|
|
||||||
key: str,
|
|
||||||
value: Any,
|
|
||||||
timestamp: Optional[Union[int, datetime]] = None) -> None:
|
|
||||||
"""
|
|
||||||
Add a new data point or update an existing one.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
key : str
|
|
||||||
The data key. Must pass :meth:`_is_valid_key`.
|
|
||||||
value : Any
|
|
||||||
The data value to store.
|
|
||||||
timestamp : int | datetime | None
|
|
||||||
When the data was recorded. If omitted, current time is used.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If the key is not valid.
|
|
||||||
"""
|
|
||||||
if not self._is_valid_key(key):
|
|
||||||
raise ValueError(f"Invalid key '{key}'. Must contain only "
|
|
||||||
"letters, digits, or underscores.")
|
|
||||||
|
|
||||||
ts = _ensure_timestamp(timestamp)
|
|
||||||
|
|
||||||
# Update sensor-level timestamps
|
|
||||||
self.last_seen = max(self.last_seen, ts)
|
|
||||||
|
|
||||||
# Search for existing entry
|
|
||||||
for entry in self.data:
|
|
||||||
if entry["name"] == key:
|
|
||||||
# Update existing entry
|
|
||||||
entry["value"] = value
|
|
||||||
entry["last_seen"] = ts
|
|
||||||
if ts < entry["first_seen"]:
|
|
||||||
entry["first_seen"] = ts
|
|
||||||
return
|
|
||||||
|
|
||||||
# If we get here, it's a new key
|
|
||||||
new_entry: Dict[str, Any] = {
|
|
||||||
"name": key,
|
|
||||||
"value": value,
|
|
||||||
"first_seen": ts,
|
|
||||||
"last_seen": ts
|
|
||||||
}
|
|
||||||
self.data.append(new_entry)
|
|
||||||
|
|
||||||
def get_value(self, key: str) -> Optional[Any]:
|
|
||||||
"""Return the most recent value for a key, or None if it doesn't exist."""
|
|
||||||
for entry in self.data:
|
|
||||||
if entry["name"] == key:
|
|
||||||
return entry["value"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_comment(self, new_comment: str):
|
|
||||||
self.comment = new_comment
|
|
||||||
|
|
||||||
def to_dict(self, simple_return = False) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
Return the data in the exact structure requested.
|
|
||||||
|
|
||||||
Example
|
|
||||||
-------
|
|
||||||
>>> sensor.to_dict()
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{"name": "temp", "value": 23, "first_seen": 1710000000, "last_seen": 1710000005},
|
|
||||||
{"name": "hum", "value": 45, "first_seen": 1710000010, "last_seen": 1710000010}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if simple_return:
|
|
||||||
simple_return = {
|
|
||||||
"temp": self.get_value(key="temp"),
|
|
||||||
"humidity": self.get_value(key="humidity")
|
|
||||||
}
|
|
||||||
return simple_return
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"data": self.data.copy()}
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
# Convenience & debugging
|
|
||||||
# ----------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (f"{self.__class__.__name__}(name={self.name!r}, "
|
|
||||||
f"first_seen={self.first_seen}, last_seen={self.last_seen}, "
|
|
||||||
f"data={self.data!r})")
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Example usage
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Instantiate a sensor
|
|
||||||
sensor = Sensor(name="weather_station_01")
|
|
||||||
|
|
||||||
# Add some data
|
|
||||||
sensor.add_or_update("temperature", 23.5)
|
|
||||||
sensor.add_or_update("humidity", 45)
|
|
||||||
|
|
||||||
# Update an existing key
|
|
||||||
sensor.add_or_update("temperature", 24.0)
|
|
||||||
|
|
||||||
# Try an invalid key (will raise ValueError)
|
|
||||||
try:
|
|
||||||
sensor.add_or_update("bad key!", 0)
|
|
||||||
except ValueError as e:
|
|
||||||
print(e)
|
|
||||||
|
|
||||||
# Print the structured data
|
|
||||||
import json
|
|
||||||
print(json.dumps(sensor.to_dict(), indent=4))
|
|
||||||
+1
-157
@@ -237,82 +237,6 @@ $systemComponents = $properties['system_components'] ?? [];
|
|||||||
$selectedHost = $clients[$selectedIdx]['hostname'] ?? 'Unknown';
|
$selectedHost = $clients[$selectedIdx]['hostname'] ?? 'Unknown';
|
||||||
|
|
||||||
|
|
||||||
/* ---- Sidebar Renderer ---- */
|
|
||||||
|
|
||||||
function renderSidebar(string $mode){
|
|
||||||
global $clients, $client, $properties, $systemProperties, $systemComponents, $selectedHost, $selectedHosts, $selectedId, $selectedIdx, $remove_hosts;
|
|
||||||
|
|
||||||
$modes = [
|
|
||||||
'cosmostat' => 'Cosmostat',
|
|
||||||
/* 'gali' => 'Shuttle Gali',*/
|
|
||||||
'drive_health' => 'Drive Health',
|
|
||||||
];
|
|
||||||
|
|
||||||
?>
|
|
||||||
<nav class="sidebar">
|
|
||||||
<form method="get" id="modeForm">
|
|
||||||
<label for="modeSelect"></label>
|
|
||||||
<select class="select-dark" name="mode" id="modeSelect" onchange="this.form.submit()">
|
|
||||||
<?php foreach ($modes as $key => $label): ?><option value="<?= h($key) ?>" <?= $mode === $key ? 'selected' : '' ?>><?= h($label) ?></option>
|
|
||||||
<?php endforeach; ?></select>
|
|
||||||
</form>
|
|
||||||
<p>
|
|
||||||
<?php if ($mode === 'drive_health'): ?>
|
|
||||||
<?php
|
|
||||||
if ( !is_array($clients) || empty($clients) ) {
|
|
||||||
// Graceful “no data” handling
|
|
||||||
echo '<p style="margin:1rem 0; font-style:italic;">'
|
|
||||||
. 'No hosts are available to manage at this time.'
|
|
||||||
. '</p>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<?php if (is_array($clients) && !empty($clients)): ?>
|
|
||||||
|
|
||||||
<h3>Hosts</h3>
|
|
||||||
<form method="get" id="driveHealthForm">
|
|
||||||
<input type="hidden" name="mode" value="drive_health">
|
|
||||||
<ul>
|
|
||||||
<?php foreach ($clients as $c): ?>
|
|
||||||
<?php $id = $c['short_id']; ?>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="hosts[]" value="<?= h($id) ?>" <?= in_array($id, $selectedHosts, true) ? 'checked' : '' ?>><?= h($c['name']) ?>
|
|
||||||
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul><p>
|
|
||||||
<button class="btn btn-primary" type="submit" name="action" value="apply">Apply</button><p>
|
|
||||||
<button class="btn btn-primary" type="submit" name="action" value="all">Select All</button><p>
|
|
||||||
<button class="btn btn-primary" type="submit" name="action" value="none">Select None</button><p>
|
|
||||||
</form>
|
|
||||||
<!-- Remove host button (POST) -->
|
|
||||||
<form method="post" id="removeForm">
|
|
||||||
<input type="hidden" name="mode" value="drive_health">
|
|
||||||
<input type="hidden" name="action" value="remove">
|
|
||||||
<input type="hidden" name="remove_hosts" id="remove_hosts_input" value="">
|
|
||||||
<button class="btn btn-primary" type="submit">Remove</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($mode == 'cosmostat'): ?>
|
|
||||||
|
|
||||||
<input type="hidden" name="host" value="<?= h($selectedId) ?>">
|
|
||||||
<h3>Endpoints</h3>
|
|
||||||
<ol id="endpointList"></ol>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($mode == 'gali'): ?>
|
|
||||||
<h3>Shuttle Gali</h3>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Main Content Renderer ---- */
|
/* ---- Main Content Renderer ---- */
|
||||||
|
|
||||||
function renderMainContent(string $mode){
|
function renderMainContent(string $mode){
|
||||||
@@ -322,71 +246,6 @@ function renderMainContent(string $mode){
|
|||||||
die('<div class="card"><p style="color:red;">Could not retrieve data from the API for mode "' . h($mode) . '".</p></div>');
|
die('<div class="card"><p style="color:red;">Could not retrieve data from the API for mode "' . h($mode) . '".</p></div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($mode === 'drive_health') {
|
|
||||||
// If nothing is selected, show a friendly message
|
|
||||||
if (empty($selectedHosts)) {
|
|
||||||
echo '<div class="card"><p>No hosts selected.</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
echo '
|
|
||||||
<div class="storage_client">';
|
|
||||||
foreach ($selectedHosts as $sid) {
|
|
||||||
// Find the client that matches this short_id
|
|
||||||
$c = null;
|
|
||||||
foreach ($clients as $cl) {
|
|
||||||
if ($cl['short_id'] === $sid) {
|
|
||||||
$c = $cl;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($c === null) continue; // safety
|
|
||||||
|
|
||||||
$hostname = $c['name'] ?? 'Unknown';
|
|
||||||
echo '
|
|
||||||
<div class="card">
|
|
||||||
<h2>Drive Health - ' . h($hostname) . '</h2>
|
|
||||||
<h3>IP: '.h($c['ip']).'<br>
|
|
||||||
Timestamp: '.date('F j, Y g:i a', (int) $c['timestamp']).' </h3>
|
|
||||||
';
|
|
||||||
|
|
||||||
if (isset($c['drives']) && is_array($c['drives']) && count($c['drives']) > 0) {
|
|
||||||
echo '
|
|
||||||
<table id="host_metrics_table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>Drive Letter</th><th>Disk ID</th><th>Health Metrics</th><th>Model</th><th>Serial</th><th>Capacity</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>';
|
|
||||||
foreach ($c['drives'] as $drive) {
|
|
||||||
$wear_gb = '';
|
|
||||||
if (isset($drive['host_writes'])){
|
|
||||||
$wear_gb = $drive['host_writes'].' wear';
|
|
||||||
}
|
|
||||||
echo '
|
|
||||||
<tr>
|
|
||||||
<td>' . h($drive['drive_letter'] ?? '') . '</td>
|
|
||||||
<td>' . h("".$drive['disk_id'] ?? '') . '</td>
|
|
||||||
<td>' .
|
|
||||||
h($drive['health_status'] ?? '').'<br>'.
|
|
||||||
h($drive['power_on_hours'] ?? '').'<br>'.
|
|
||||||
h($wear_gb ?? '').
|
|
||||||
'</td>
|
|
||||||
<td>' . h($drive['model'] ?? '') . '</td>
|
|
||||||
<td>' . h($drive['serial'] ?? '') . '</td>
|
|
||||||
<td>' . h($drive['capacity'] ?? '') . '</td>
|
|
||||||
</tr>';
|
|
||||||
}
|
|
||||||
echo '
|
|
||||||
</tbody></table> ';
|
|
||||||
} else {
|
|
||||||
echo '<p>No drive data available for this host.</p>';
|
|
||||||
}
|
|
||||||
echo '
|
|
||||||
</div>';
|
|
||||||
}
|
|
||||||
echo '
|
|
||||||
</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
@@ -471,16 +330,13 @@ function renderMainContent(string $mode){
|
|||||||
|
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<?php renderSidebar($mode); ?>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<?php renderMainContent($mode); ?>
|
<?php renderMainContent($mode); ?>
|
||||||
|
|
||||||
|
|
||||||
</div> <!-- /wrapper -->
|
</div> <!-- /wrapper -->
|
||||||
|
|
||||||
<!-- cosmostat javascript -->
|
<!-- javascript -->
|
||||||
<script src="socket.io/socket.io.js"></script>
|
<script src="socket.io/socket.io.js"></script>
|
||||||
<script src="src/system_metrics.js"></script>
|
<script src="src/system_metrics.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -495,18 +351,6 @@ function renderMainContent(string $mode){
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- drive health javascript -->
|
|
||||||
<script>
|
|
||||||
// Removal Handler
|
|
||||||
document.getElementById('removeForm').addEventListener('submit', function (e) {
|
|
||||||
// Grab all checked checkboxes from the Drive-Health form
|
|
||||||
const checked = document.querySelectorAll('#driveHealthForm input[type="checkbox"][name="hosts[]"]:checked');
|
|
||||||
const ids = Array.from(checked).map(cb => cb.value);
|
|
||||||
|
|
||||||
// Pass the comma-separated list to the hidden input
|
|
||||||
document.getElementById('remove_hosts_input').value = ids.join(',');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user