From 31d1bf23362a9dcce2d10db7e0c7963f32777500 Mon Sep 17 00:00:00 2001 From: DjeAvd Date: Sun, 29 Mar 2026 17:12:03 +0100 Subject: [PATCH] feat: add BLE-to-MQTT gateway implementation --- gateway/check_uuid.py | 13 +++ gateway/gateway.py | 242 ++++++++++++++++++++++++++++++++++++++++++ gateway/scan.py | 10 ++ 3 files changed, 265 insertions(+) create mode 100644 gateway/check_uuid.py create mode 100644 gateway/gateway.py create mode 100644 gateway/scan.py diff --git a/gateway/check_uuid.py b/gateway/check_uuid.py new file mode 100644 index 0000000..4b68e74 --- /dev/null +++ b/gateway/check_uuid.py @@ -0,0 +1,13 @@ +import asyncio +from bleak import BleakScanner + +async def scan(): + print("Scan en cours (10 secondes)...") + devices = await BleakScanner.discover(timeout=10.0, return_adv=True) + for address, (device, adv) in devices.items(): + if device.name and "Djelal" in device.name: + print(f"\n{device.name} | {address}") + print(f" Service UUIDs : {adv.service_uuids}") + print(f" Local name : {adv.local_name}") + +asyncio.run(scan()) diff --git a/gateway/gateway.py b/gateway/gateway.py new file mode 100644 index 0000000..722026e --- /dev/null +++ b/gateway/gateway.py @@ -0,0 +1,242 @@ +import asyncio +import json +import csv +import os +from datetime import datetime, timezone +from bleak import BleakClient, BleakScanner +import paho.mqtt.client as mqtt + +# --------------------------------------------------------------------------- +# GATT service and characteristic UUIDs for the Nordic Thingy:52 +# These UUIDs are proprietary to Nordic Semiconductor and are used to +# identify the environmental sensor service and its characteristics over BLE. +# +# THINGY_SERVICE_UUID corresponds to the Configuration service (ef680100), +# which is the UUID advertised by the Thingy:52 in its BLE advertising packets. +# Note: the Environment service (ef680200) is available after connection +# but is not advertised, so it cannot be used for device discovery. +# --------------------------------------------------------------------------- +THINGY_SERVICE_UUID = "ef680100-9b35-4933-9b10-52ffa9740042" +UUID_TEMP = "ef680201-9b35-4933-9b10-52ffa9740042" +UUID_CO2 = "ef680204-9b35-4933-9b10-52ffa9740042" +UUID_HUMIDITY = "ef680203-9b35-4933-9b10-52ffa9740042" + +# --------------------------------------------------------------------------- +# MQTT broker configuration +# The broker runs locally on the Raspberry Pi (Mosquitto). +# Other teams subscribe to the topics published here. +# --------------------------------------------------------------------------- +MQTT_BROKER = "localhost" +MQTT_PORT = 1883 + +# Publishing interval in seconds (300 = every 5 minutes in production) +INTERVAL = 300 + +# --------------------------------------------------------------------------- +# Room identification +# The operator enters the room ID at startup. This value is used to name +# the output files and structure the MQTT topics accordingly. +# --------------------------------------------------------------------------- + +print("=== Gateway IoT - HES-SO ===") +ROOM_ID = input("Which room are you in? (e.g. C1, A2, B5) : ").strip().upper() +print(f"Room configured : {ROOM_ID}\n") + +# --------------------------------------------------------------------------- +# Output file paths +# One CSV and one JSON file are created per room, named after the room ID. +# The CSV is intended for local analysis (e.g. Excel). +# The JSON follows the format expected by the database team. +# --------------------------------------------------------------------------- +BASE_PATH = "/home/pi/gateway" +CSV_FILE = f"{BASE_PATH}/data_{ROOM_ID}.csv" +JSON_FILE = f"{BASE_PATH}/data_{ROOM_ID}.json" + +# --------------------------------------------------------------------------- +# Runtime state +# discovered : maps each device MAC address to its assigned node ID +# latest : stores the most recent sensor values for each device +# connecting : tracks MAC addresses currently being connected to +# to prevent duplicate connection attempts during BLE scanning +# --------------------------------------------------------------------------- +discovered = {} +latest = {} +connecting = set() +thingy_counter = [0] +scanner = None + +# --------------------------------------------------------------------------- +# MQTT client setup +# The client connects to the local Mosquitto broker and starts its +# background loop, which handles message publishing asynchronously. +# --------------------------------------------------------------------------- +mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) +mqttc.connect(MQTT_BROKER, MQTT_PORT) +mqttc.loop_start() + +# --------------------------------------------------------------------------- +# CSV initialisation +# The header row is written only if the file does not already exist, +# so that existing data is preserved across restarts. +# --------------------------------------------------------------------------- +if not os.path.exists(CSV_FILE): + with open(CSV_FILE, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["timestamp", "room_id", "node_id", "temperature_c", "humidity_pct", "co2_ppm"]) + +# --------------------------------------------------------------------------- +# BLE notification handlers +# These functions are called automatically by bleak each time the Thingy:52 +# sends a new value for the corresponding characteristic. +# +# Temperature encoding: 2 bytes +# byte 0 = integer part +# byte 1 = decimal part (hundredths) +# +# Humidity encoding: 1 byte +# byte 0 = relative humidity in percent +# +# CO2 encoding: 4 bytes (little-endian) +# bytes 0-1 = eCO2 in ppm (estimated CO2 derived from VOC measurement) +# bytes 2-3 = TVOC in ppb (not used here) +# A value of 0 indicates the sensor is still warming up. +# --------------------------------------------------------------------------- +def handle_temp(mac, sender, data): + latest[mac]["temp"] = data[0] + data[1] / 100 + +def handle_humidity(mac, sender, data): + latest[mac]["humidity"] = data[0] + +def handle_co2(mac, sender, data): + co2 = int.from_bytes(data[0:2], byteorder='little') + if co2 > 0: + latest[mac]["co2"] = co2 + +# --------------------------------------------------------------------------- +# Data persistence +# Each measurement is appended to both the CSV and JSON files. +# The JSON file stores a list of all records, which is reloaded and +# extended on each write to preserve the full history. +# --------------------------------------------------------------------------- +def save_data(payload): + with open(CSV_FILE, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + payload["timestamp"], + payload["room_id"], + payload["node_id"], + payload["sensors"]["temperature_c"], + payload["sensors"]["humidity_pct"], + payload["sensors"]["co2_ppm"] + ]) + records = [] + if os.path.exists(JSON_FILE): + with open(JSON_FILE, "r") as f: + try: + records = json.load(f) + except: + records = [] + records.append(payload) + with open(JSON_FILE, "w") as f: + json.dump(records, f, indent=2) + +# --------------------------------------------------------------------------- +# BLE device connection +# The scanner is stopped before connecting to avoid BLE stack conflicts on +# the Raspberry Pi. Once the connection is established and notifications are +# registered, the scanner is restarted to detect additional nodes. +# --------------------------------------------------------------------------- +async def connect_device(mac): + global scanner + try: + if scanner: + await scanner.stop() + await asyncio.sleep(1) + + client = BleakClient(mac) + await client.connect() + + node_id = discovered[mac] + connecting.discard(mac) + print(f"{node_id} ({mac}) connected") + + await client.start_notify(UUID_TEMP, lambda s, d: handle_temp(mac, s, d)) + await client.start_notify(UUID_CO2, lambda s, d: handle_co2(mac, s, d)) + await client.start_notify(UUID_HUMIDITY, lambda s, d: handle_humidity(mac, s, d)) + + await asyncio.sleep(1) + await scanner.start() + print("Scanner restarted, waiting for additional nodes...") + + except Exception as e: + print(f"Connection error for {mac} : {e}") + connecting.discard(mac) + discovered.pop(mac, None) + latest.pop(mac, None) + if scanner: + await scanner.start() + +# --------------------------------------------------------------------------- +# BLE discovery callback +# Called by bleak for every advertising packet received during the scan. +# A device is accepted only if it advertises the Thingy:52 environment +# service UUID, and only if it has not already been discovered or is not +# currently being connected to. +# --------------------------------------------------------------------------- +def on_device_found(device, adv_data): + uuids = [str(u).lower() for u in adv_data.service_uuids] + if THINGY_SERVICE_UUID.lower() in uuids \ + and device.address not in discovered \ + and device.address not in connecting: + connecting.add(device.address) + thingy_counter[0] += 1 + node_id = f"{ROOM_ID}_thingy{thingy_counter[0]}" + discovered[device.address] = node_id + latest[device.address] = {"temp": None, "humidity": None, "co2": None} + print(f"New node detected, assigned name: {node_id} ({device.address})") + asyncio.get_event_loop().create_task(connect_device(device.address)) + +# --------------------------------------------------------------------------- +# Periodic MQTT publishing +# Every INTERVAL seconds, the latest sensor values from all connected nodes +# are formatted as a JSON payload and published to the MQTT broker. +# The topic structure follows: classroom/{room_id}/{node_id} +# Incomplete readings (sensor still warming up) are skipped for that cycle. +# --------------------------------------------------------------------------- +async def publish_every_interval(): + while True: + await asyncio.sleep(INTERVAL) + now = datetime.now(timezone.utc).isoformat() + print(f"\n--- {datetime.now().strftime('%H:%M:%S')} ---") + for mac, values in latest.items(): + node_id = discovered.get(mac, mac) + if any(v is None for v in values.values()): + print(f"{node_id} | incomplete data, waiting for sensor warmup...") + continue + payload = { + "timestamp": now, + "room_id": ROOM_ID, + "node_id": node_id, + "sensors": { + "co2_ppm": values["co2"], + "temperature_c": round(values["temp"], 2), + "humidity_pct": values["humidity"] + } + } + topic = f"classroom/{ROOM_ID}/{node_id}" + mqttc.publish(topic, json.dumps(payload)) + save_data(payload) + print(f"{node_id} | Temp: {values['temp']:.2f} C | Humidity: {values['humidity']}% | CO2: {values['co2']} ppm | Published") + +# --------------------------------------------------------------------------- +# Entry point +# Starts the BLE scanner and runs the publishing loop indefinitely. +# --------------------------------------------------------------------------- +async def main(): + global scanner + print("BLE scan started, waiting for Thingy:52 nodes...\n") + scanner = BleakScanner(detection_callback=on_device_found) + await scanner.start() + await publish_every_interval() + +asyncio.run(main()) diff --git a/gateway/scan.py b/gateway/scan.py new file mode 100644 index 0000000..abaa7ed --- /dev/null +++ b/gateway/scan.py @@ -0,0 +1,10 @@ +import asyncio +from bleak import BleakScanner + +async def scan(): + print("Scan BLE en cours (5 secondes)...") + devices = await BleakScanner.discover(timeout=5.0) + for d in devices: + print(f"{d.name} | {d.address}") + +asyncio.run(scan())