From 8567e9998ab6565362c8c79407c298dfcc2b407a Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 12 Oct 2026 10:51:12 -0700 Subject: [PATCH] init commit --- README.md | 13 +++ app.py | 53 ++++++++++++ ble.py | 121 +++++++++++++++++++++++++++ config.py | 3 + requirements-1.txt | 9 ++ requirements.txt | 8 ++ run.sh | 11 +++ scanner.py | 123 +++++++++++++++++++++++++++ sensors.py | 203 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 544 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 ble.py create mode 100644 config.py create mode 100644 requirements-1.txt create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 scanner.py create mode 100644 sensors.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..d844c98 --- /dev/null +++ b/README.md @@ -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 + diff --git a/app.py b/app.py new file mode 100644 index 0000000..31365c9 --- /dev/null +++ b/app.py @@ -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) \ No newline at end of file diff --git a/ble.py b/ble.py new file mode 100644 index 0000000..45020f1 --- /dev/null +++ b/ble.py @@ -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}") diff --git a/config.py b/config.py new file mode 100644 index 0000000..9816ea3 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/requirements-1.txt b/requirements-1.txt new file mode 100644 index 0000000..ccbe582 --- /dev/null +++ b/requirements-1.txt @@ -0,0 +1,9 @@ +anyio +APScheduler +bleak +cryptography +Flask +click +setuptools + +Flask-APScheduler \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19f7a52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +anyio +bleak +cryptography +click +setuptools +Flask-APScheduler +Flask +APScheduler \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..64ca2c0 --- /dev/null +++ b/run.sh @@ -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 \ No newline at end of file diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..9fcf618 --- /dev/null +++ b/scanner.py @@ -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") diff --git a/sensors.py b/sensors.py new file mode 100644 index 0000000..e3667f0 --- /dev/null +++ b/sensors.py @@ -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)) \ No newline at end of file