1
0
This repository has been archived on 2026-06-27. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
MSE-PI-E2EEDA-Plein-de-eeee…/gateway/gateway.py

243 lines
10 KiB
Python

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())