init commit

This commit is contained in:
matt committed 2026-10-12 10:51:12 -07:00
commit 8567e9998a
9 files changed
+544

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
Gonna use the intern to build this temp sensing thing.
ble-scanner/
├─ app.py # Flask + Scheduler + API
├─ ble.py # BLE helper functions to read temp+humidity data
├─ scanner.py # Async BLE scan + cache logic
├─ config.py # Shared constants (prefix, interval, etc.)
├─ requirements.txt
├─ run.sh # Run API from shell
├─ scanner.py # Handles scanning for Mii sensors, other helper functions
├─ sensors.py # Object classifier
└─ README.md
+53
View File
@@ -0,0 +1,53 @@
from flask import Flask, jsonify
from flask_apscheduler import APScheduler
from datetime import datetime, timedelta
from scanner import update_cache, get_cache_snapshot, get_sensor_summary, get_sensor_details, scan_macs
from config import SCAN_INTERVAL_MINUTES
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='0.0.0.0', port=5000, debug=False)
+121
View File
@@ -0,0 +1,121 @@
import asyncio
from bleak import BleakClient, BleakScanner
import warnings
warnings.filterwarnings("ignore")
# 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})")
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 = await client.get_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}%")
print("")
return temp, hum
def sensor_data_query(mac_list):
result = []
for mac in mac_list:
this_result = asyncio.run(read_data(mac))
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}")
+3
View File
@@ -0,0 +1,3 @@
# --- BLE & app config -------------------------------------------------
MAC_PREFIX = "A4:C1:38" # 3 octets (change to what you need)
SCAN_INTERVAL_MINUTES = 1 # how often to run the scan
+9
View File
@@ -0,0 +1,9 @@
anyio
APScheduler
bleak
cryptography
Flask
click
setuptools
Flask-APScheduler
+8
View File
@@ -0,0 +1,8 @@
anyio
bleak
cryptography
click
setuptools
Flask-APScheduler
Flask
APScheduler
Executable
+11
View File
@@ -0,0 +1,11 @@
# 3. Create a venv (optional but recommended)
python3 -m venv .venv
source .venv/bin/activate
# 4. Install dependencies
pip install -r requirements.txt
# 5. Start
#python ble.py
python app.py
+123
View File
@@ -0,0 +1,123 @@
import asyncio
import threading
import time
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 MAC_PREFIX
from sensors import *
from ble import *
# In-memory cache + thread-safe lock
_mac_cache: Set[str] = set()
_lock = threading.Lock()
_sensors_cache = []
# other vars
MI_SERVICE_UUID = "ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"
TEMP_CHAR_UUID = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"
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())
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)]
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.
new_macs = scan_macs()
print(f"[Scanner] Updated cache, {len(_mac_cache)} unique MAC(s).")
print("Updating data for current MACs...")
sensor_data = None
try:
#asyncio.run(update_data())
sensor_data = sensor_data_query(new_macs)
print(sensor_data)
except Exception as e:
print(f"Aww fuck, no data :(- {e}")
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'])
def scan_macs():
new_macs = asyncio.run(_scan_once())
with _lock:
print("MACs found:")
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)
return new_macs
def update_data():
for mac in set(_mac_cache):
print(f"Test - {mac}")
result = asyncio.run(read_data(mac))
def get_cache_snapshot() -> Set[str]:
"""Return a thread-safe snapshot of the current cache."""
with _lock:
return set(_mac_cache)
def get_sensor_details():
sensor_list = []
for sensor in _sensors_cache:
print(f"Summary - {sensor.name}")
new_sensor = {
"name": sensor.name,
# "first_seen": ts_to_pst_string(sensor.first_seen),
# "last_seen": ts_to_pst_string(sensor.last_seen),
"first_seen": sensor.first_seen,
"last_seen": sensor.last_seen,
"comment": sensor.comment,
"data": sensor.to_dict()
}
sensor_list.append(new_sensor)
result = {
"sensors": sensor_list
}
# print(result)
return result
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
}
# print(result)
return result
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")
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Union
# --------------------------------------------------------------------------- #
# 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
# ----------------------------------------------------------------------- #
# 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))