From 2dd664c4b4721aae61a24adbc785922895df35ac Mon Sep 17 00:00:00 2001 From: Matthias Guillitte Date: Thu, 23 Jul 2026 15:50:43 +0200 Subject: [PATCH] UART & Sensors --- cloud/app.py | 23 +- deploy.sh | 7 +- micro_ondes/README.md | 13 +- micro_ondes/esp_lora/main.py | 2 +- micro_ondes/esp_wifi/boot.py | 6 +- micro_ondes/esp_wifi/main.py | 92 +- micro_ondes/esp_wifi/sensors/__init__.py | 1 + .../esp_wifi/sensors/temperature_gun.py | 969 ++++++++++++++++++ orchestrateur/main.py | 125 ++- orchestrateur/requirements.txt | 6 +- orchestrateur/sensors/__init__.py | 7 + orchestrateur/sensors/button.py | 44 + orchestrateur/sensors/camera.py | 33 + orchestrateur/sensors/gps.py | 122 +++ orchestrateur/sensors/lib/__init__.py | 2 + .../sensors/lib/grove_i2c_temp_hum_mini.py | 87 ++ orchestrateur/sensors/lib/grovepi_old.py | 691 +++++++++++++ orchestrateur/sensors/lock.py | 5 + orchestrateur/sensors/temp_hum.py | 52 + orchestrateur/sensors/ultrasonicRanger.py | 32 + shared/__init__.py | 1 + shared/config.py | 9 +- shared/logging.py | 6 + shared/mqtt.py | 46 +- shared/payloads.py | 23 + 25 files changed, 2333 insertions(+), 71 deletions(-) create mode 100644 micro_ondes/esp_wifi/sensors/__init__.py create mode 100644 micro_ondes/esp_wifi/sensors/temperature_gun.py create mode 100644 orchestrateur/sensors/__init__.py create mode 100644 orchestrateur/sensors/button.py create mode 100644 orchestrateur/sensors/camera.py create mode 100644 orchestrateur/sensors/gps.py create mode 100644 orchestrateur/sensors/lib/__init__.py create mode 100644 orchestrateur/sensors/lib/grove_i2c_temp_hum_mini.py create mode 100644 orchestrateur/sensors/lib/grovepi_old.py create mode 100644 orchestrateur/sensors/lock.py create mode 100644 orchestrateur/sensors/temp_hum.py create mode 100644 orchestrateur/sensors/ultrasonicRanger.py create mode 100644 shared/logging.py diff --git a/cloud/app.py b/cloud/app.py index 136c729..909d5bb 100644 --- a/cloud/app.py +++ b/cloud/app.py @@ -20,9 +20,9 @@ db = client["microwave_network_db"] cooking_collection = db["cooking_parameters"] device_network_collection = db["device_network"] -# Ensure the photo storage directory exists when the app starts -PHOTO_DIR = "storage/dishPhotos" -os.makedirs(PHOTO_DIR, exist_ok=True) +# Ensure the camera image storage directory exists when the app starts +CAMERA_IMAGE_DIR = "storage/dishCameraImages" +os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True) # --------------------------------------------------------- # Routes @@ -40,24 +40,24 @@ def cooking_params(): if not data: return jsonify({"error": "Invalid or missing JSON payload"}), 400 - # 1. Handle the Photo - photo_b64 = data.get("photo") - if photo_b64: + # 1. Handle the Camera Image + camera_image_b64 = data.get("camera_image") + if camera_image_b64: # Generate a unique filename using UUID to avoid overwriting filename = f"dish_{uuid.uuid4().hex}.jpg" - filepath = os.path.join(PHOTO_DIR, filename) + filepath = os.path.join(CAMERA_IMAGE_DIR, filename) try: # Decode the base64 string and save it as a binary file with open(filepath, "wb") as f: - f.write(base64.b64decode(photo_b64)) + f.write(base64.b64decode(camera_image_b64)) # Replace the giant base64 string in the dictionary with the local file path # so we don't bloat the MongoDB document - data["photo"] = filepath + data["camera_image"] = filepath except Exception as e: - return jsonify({"error": f"Failed to save photo: {str(e)}"}), 500 + return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500 # 2. Save to MongoDB try: @@ -70,6 +70,9 @@ def cooking_params(): except Exception as e: return jsonify({"error": f"Database error: {str(e)}"}), 500 + + # 3. Returns with the cooking parameters + @app.route("/device-network", methods=["POST"]) diff --git a/deploy.sh b/deploy.sh index 252d9a5..5cda3f5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -14,7 +14,7 @@ RPI_SYSTEMD_SERVICE="smartwave.service" # Vérification des arguments if [ -z "$1" ]; then - echo "Usage: ./deploy.sh [wifi|lora|rpi|all]" + echo "Usage: ./deploy.sh [wifi|mqtt|lora|rpi|all]" exit 1 fi @@ -79,6 +79,9 @@ case $CIBLE in "wifi") deploy_to_esp "micro_ondes/esp_wifi" "$PORT_ESP_WIFI" "ESP-WIFI" ;; + "mqtt") + deploy_to_esp "micro_ondes/esp_wifi" "$PORT_ESP_WIFI" "ESP-WIFI" + ;; "lora") deploy_to_esp "micro_ondes/esp_lora" "$PORT_ESP_LORA" "ESP-LORA" ;; @@ -92,6 +95,6 @@ case $CIBLE in # Ajoute les autres ici ;; *) - echo "Cible inconnue. Utilise 'wifi', 'lora' ou 'all'." + echo "Cible inconnue. Utilise 'wifi', 'lora', 'mqtt' ou 'all'." ;; esac \ No newline at end of file diff --git a/micro_ondes/README.md b/micro_ondes/README.md index f59daff..6b200e4 100644 --- a/micro_ondes/README.md +++ b/micro_ondes/README.md @@ -1,5 +1,16 @@ # LoRa -`mpremote connect /dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0 repl` +`mpremote connect /dev/serial/by-path/pci-0000:00:14.0-usb-0:6.1:1.0-port0 repl` +# MQTT + +`mpremote connect /dev/serial/by-path/pci-0000:00:14.0-usb-0:6.2:1.0-port0 repl` + +# UART + +| LoRa | MQTT | +| --- | --- | +| 45 | P17 | +| 46 | P16 | +| GND | GND | diff --git a/micro_ondes/esp_lora/main.py b/micro_ondes/esp_lora/main.py index f6fb6f3..08cc2f8 100644 --- a/micro_ondes/esp_lora/main.py +++ b/micro_ondes/esp_lora/main.py @@ -61,7 +61,7 @@ while True: command = uart_device.read() print(f"[Main] Received command from WiFi Board: {command}") - uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}") + # uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}") # 2. Send local metrics over the wire to the WiFi board every few seconds # uart_device.send("Data Pack: LoRa Link RSSI -72dBm") diff --git a/micro_ondes/esp_wifi/boot.py b/micro_ondes/esp_wifi/boot.py index 1711274..818a88c 100644 --- a/micro_ondes/esp_wifi/boot.py +++ b/micro_ondes/esp_wifi/boot.py @@ -1,6 +1,6 @@ # This file is executed on every boot (including wake-boot from deepsleep) -#import esp -#esp.osdebug(None) +import esp +esp.osdebug(True) #import webrepl #webrepl.start() @@ -16,4 +16,4 @@ def do_connect(ssid, pwd): print('network config:', sta_if.ifconfig()) # Attempt to connect to WiFi network -do_connect("Smartwave-1", 'Smartwave-prot-1') +# do_connect("Smartwave-1", 'Smartwave-prot-1') diff --git a/micro_ondes/esp_wifi/main.py b/micro_ondes/esp_wifi/main.py index 979fba2..d9f8d98 100644 --- a/micro_ondes/esp_wifi/main.py +++ b/micro_ondes/esp_wifi/main.py @@ -1,8 +1,11 @@ import _thread import select from machine import Pin -from shared import get_mqtt_client, get_uart, config +from sensors import temperature_gun +from shared import get_mqtt_client, get_uart, config, payloads import time +import ujson as json +import sys # Simple thread-safe queue list msg_queue = [] @@ -13,17 +16,22 @@ def queue_publish(topic, payload): with queue_lock: msg_queue.append((topic, payload)) -# --- Hardware & Client Setup --- -vext = Pin(19, Pin.OUT) -vext.value(0) -time.sleep_ms(100) +# --- INITIALIZE CAMERA --- +try: + # Pass your confirmed working SCL and SDA pins here + temperature_gun.init_camera(scl_pin=21, sda_pin=22, freq=100000) +except Exception as e: + print("[Main] Critical: Camera setup failed!") + sys.print_exception(e) +# --- READ DEVICE ID --- try: with open("device_id.txt", "r") as f: DEVICE_ID = f.read().strip() except Exception: DEVICE_ID = "ESP32_Inconnu" +# --- MQTT SETUP --- MQTT_CA_FILE = "/certs/ca.crt" mqtt_client = get_mqtt_client( @@ -34,8 +42,28 @@ mqtt_client = get_mqtt_client( keepalive=config.MQTT_KEEPALIVE, ) +global orchestrator_id +orchestrator_id = None def on_mqtt_message(message): print("[MQTT Thread] Received message:", message) + + # Try and parse the payload as json, but if it fails, just print the raw payload + payload_data=None + try: + payload_data = json.loads(message['payload']) + except Exception as e: + print("[MQTT Thread] Error parsing JSON:", e) + sys.print_exception(e) + pass # Maybe it's not JSON + + if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID: + print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"]) + global orchestrator_id + orchestrator_id = payload_data["id_orchestrator"] + # Unsubscribe from the hello topic since we got a response + mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO) + print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO) + print("[MQTT Thread] Message processing complete.") mqtt_client.set_callback(on_mqtt_message) @@ -73,7 +101,7 @@ def mqtt_background_thread(): # 3. Handle Keepalive tracking manually if time.time() - last_check >= 15: - print("[Thread] Sending keepalive ping...") + # print("[Thread] Sending keepalive ping...") mqtt_client._client.ping() last_check = time.time() @@ -82,6 +110,7 @@ def mqtt_background_thread(): except Exception as e: print("[Thread] Connection dropped or error encountered:", e) + sys.print_exception(e) print("[Thread] Cleaning up socket context. Retrying in 5 seconds...") # --- FIX FOR ERROR 23 (SOCKET LEAK) --- @@ -99,29 +128,54 @@ def mqtt_background_thread(): except Exception: pass time.sleep(5) + +# --- UART BACKGROUND THREAD --- +def uart_background_thread(): + """Background UART worker handling all serial operations safely.""" + print("[Thread] Background UART worker started.") + + uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16) + + while True: + try: + # 1. Check for incoming messages from the Heltec board + while uart_device.any(): + incoming_msg = uart_device.read() + print(f"[Thread] Received from esp-lora over UART: {incoming_msg}") + + # 2. Example: Send data to the Heltec board every 5 seconds + # uart_device.send("Status Check: WiFi Active") -# UART -uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16) + time.sleep(5) # Fast responsive polling loop for local UART + + except Exception as e: + print("[Thread] UART error encountered:", e) + time.sleep(5) # --- Launch background worker --- -_thread.start_new_thread(mqtt_background_thread, ()) +# _thread.start_new_thread(mqtt_background_thread, ()) +# _thread.start_new_thread(uart_background_thread, ()) # --- MAIN APPLICATION THREAD (Core 0) --- print("[Main] Main execution path active.") time.sleep(2) # Give the thread a moment to initial connect +mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL +mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) while True: - print("[Main] Queueing a test message for MQTT...") - # Instead of direct publishing, push it to the queue safely - queue_publish(config.MQTT_TOPIC_SENSOR, "Hello from ESP32!") - - # 1. Check if the Heltec V3 sent us something over the wire - while uart_device.any(): - incoming_msg = uart_device.read() - print(f"[Main] Received from esp-lora over UART: {incoming_msg}") + # MQTT HELLO sent every x seconds until we get a response from the orchestrator + if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL): + print("[Main] Attempting to send initial hello to orchestrator...") + queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID)) + mqtt_hello_sent_timestamp = time.time() + pass + + # Sensors + print(f"[Main] Reading temperature from the gun sensor...") + temp = temperature_gun.read_temperature() + print(f"[Main] Temperature read: {temp}°C") # 2. Example: Send data to the Heltec board every 5 seconds # uart_device.send("Status Check: WiFi Active") - - time.sleep_ms(200) # Fast responsive polling loop for local UART \ No newline at end of file + time.sleep(1) \ No newline at end of file diff --git a/micro_ondes/esp_wifi/sensors/__init__.py b/micro_ondes/esp_wifi/sensors/__init__.py new file mode 100644 index 0000000..4f78092 --- /dev/null +++ b/micro_ondes/esp_wifi/sensors/__init__.py @@ -0,0 +1 @@ +import sensors.temperature_gun as temperature_gun \ No newline at end of file diff --git a/micro_ondes/esp_wifi/sensors/temperature_gun.py b/micro_ondes/esp_wifi/sensors/temperature_gun.py new file mode 100644 index 0000000..34930da --- /dev/null +++ b/micro_ondes/esp_wifi/sensors/temperature_gun.py @@ -0,0 +1,969 @@ +""" +Temperatue gun sensor module +using the MLX90640-D55/D110 sensor. This module provides a function to read the temperature from the gun sensor. +Resolution of 32x24 pixels, +I2C interface +Noise Equivalent Temperature difference (NETD) is 0.1K RMS @ 1Hz refresh rate +""" + +import machine # type: ignore +import math +import struct +import time +from micropython import const# Some libraries that we will use +import time + + +class RefreshRate: # pylint: disable=too-few-public-methods + """ Enum-like class for MLX90640's refresh rate """ + REFRESH_0_5_HZ = const(0b000) # 0.5Hz + REFRESH_1_HZ = const(0b001) # 1Hz + REFRESH_2_HZ = const(0b010) # 2Hz + REFRESH_4_HZ = const(0b011) # 4Hz + REFRESH_8_HZ = const(0b100) # 8Hz + REFRESH_16_HZ = const(0b101) # 16Hz + REFRESH_32_HZ = const(0b110) # 32Hz + REFRESH_64_HZ = const(0b111) # 64Hz + +class ContextManaged: + """An object that automatically deinitializes hardware with a context manager.""" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.deinit() + + # pylint: disable=no-self-use + def deinit(self): + """Free any hardware used by the object.""" + return + +class Lockable(ContextManaged): + """An object that must be locked to prevent collisions on a microcontroller resource.""" + + _locked = False + + def try_lock(self): + """Attempt to grab the lock. Return True on success, False if the lock is already taken.""" + if self._locked: + return False + self._locked = True + return True + + def unlock(self): + """Release the lock so others may use the resource.""" + if self._locked: + self._locked = False + else: + raise ValueError("Not locked") + +class I2C(Lockable): + def __init__(self, pins=(21, 22), frequency=100000): + self.init(pins, frequency) + + def init(self, pins, frequency): + self.deinit() + + # 1. Force the ESP32 to activate its internal pull-up resistors on these pins + self._pins = ( + machine.Pin(int(pins[0]), machine.Pin.IN, machine.Pin.PULL_UP), + machine.Pin(int(pins[1]), machine.Pin.IN, machine.Pin.PULL_UP) + ) + + try: + # 2. Bypasses the glitchy ESP32 hardware block using SoftI2C + # (Note: SoftI2C does not take a bus ID number like '0') + self._i2c = machine.SoftI2C(scl=self._pins[0], sda=self._pins[1], freq=frequency) + except RuntimeError: + raise + print(f"Created resilient SoftI2C: {self._i2c}") + + def deinit(self): + try: + del self._i2c + except AttributeError: + pass + + def scan(self): + return self._i2c.scan() + + def readfrom_into(self, address, buffer, *, start=0, end=None): + if start is not 0 or end is not None: + if end is None: + end = len(buffer) + buffer = memoryview(buffer)[start:end] + stop = True # remove for efficiency later + return self._i2c.readfrom_into(address, buffer) + + def writeto(self, address, buffer, *, start=0, end=None, stop=True): + if isinstance(buffer, str): + buffer = bytes([ord(x) for x in buffer]) + if start is not 0 or end is not None: + if end is None: + return self._i2c.writeto(address, memoryview(buffer)[start:], stop) + else: + return self._i2c.writeto(address, memoryview(buffer)[start:end], stop) + return self._i2c.writeto(address, buffer, stop) + +class I2CDevice: + def __init__(self, i2c, device_address, probe=True): + self.i2c = i2c + self._has_write_read = False # hasattr(self.i2c, "writeto_then_readfrom") --> has been turned to False + self.device_address = device_address + + if probe: + self.__probe_for_device() + + def readinto(self, buf, *, start=0, end=None): + if end is None: + end = len(buf) + self.i2c.readfrom_into(self.device_address, buf, start=start, end=end) + + def write(self, buf, *, start=0, end=None, stop=True): + if end is None: + end = len(buf) + self.i2c.writeto(self.device_address, buf, start=start, end=end, stop=stop) + + # pylint: disable-msg=too-many-arguments + def write_then_readinto( + self, + out_buffer, + in_buffer, + *, + out_start=0, + out_end=None, + in_start=0, + in_end=None, + stop=False + ): + if out_end is None: + out_end = len(out_buffer) + if in_end is None: + in_end = len(in_buffer) + if stop: + raise ValueError("Stop must be False. Use writeto instead.") + if self._has_write_read: + #print("c",dir(self.i2c)) + # In linux, at least, this is a special kernel function call + self.i2c.writeto_then_readfrom( + self.device_address, + out_buffer, + in_buffer, + out_start=out_start, + out_end=out_end, + in_start=in_start, + in_end=in_end, + ) + + else: + # If we don't have a special implementation, we can fake it with two calls + self.i2c.writeto(self.device_address, out_buffer, stop=False) # These lines have been changed to make it work with wipy micropython I2C module + #self.write(out_buffer, start=out_start, end=out_end, stop=False) + #self.readinto(in_buffer, start=in_start, end=in_end) + self.i2c.readfrom_into(self.device_address, in_buffer) # These lines have been changed to make it work with wipy micropython I2C module + + + # pylint: enable-msg=too-many-arguments + + def __enter__(self): + while not self.i2c.try_lock(): + pass + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.i2c.unlock() + return False + + def __probe_for_device(self): + """ + Try to read a byte from an address, + if you get an OSError it means the device is not there + or that the device does not support these means of probing + """ + while not self.i2c.try_lock(): + pass + try: + self.i2c.writeto(self.device_address, b"") + except OSError: + # some OS's dont like writing an empty bytesting... + # Retry by reading a byte + try: + result = bytearray(1) + self.i2c.readfrom_into(self.device_address, result) + except OSError: + raise ValueError("No I2C device at address: %x" % self.device_address) + finally: + self.i2c.unlock() + +eeData = [0] * const(832) +I2C_READ_LEN = const(2048) +SCALEALPHA = const(0.000001) +MLX90640_DEVICEID1 = const(0x2407) +OPENAIR_TA_SHIFT = const(8) + +class MLX90640: # pylint: disable=too-many-instance-attributes + """Interface to the MLX90640 temperature sensor.""" + + kVdd = 0 + vdd25 = 0 + KvPTAT = 0 + KtPTAT = 0 + vPTAT25 = 0 + alphaPTAT = 0 + gainEE = 0 + tgc = 0 + KsTa = 0 + resolutionEE = 0 + calibrationModeEE = 0 + ksTo = [0] * 5 + ct = [0] * 5 + alpha = [0] * 768 + alphaScale = 0 + offset = [0] * 768 + kta = [0] * 768 + ktaScale = 0 + kv = [0] * 768 + kvScale = 0 + cpAlpha = [0] * 2 + cpOffset = [0] * 2 + ilChessC = [0] * 3 + brokenPixels = [0xFFFF] * 5 + outlierPixels = [0xFFFF] * 5 + cpKta = 0 + cpKv = 0 + + def __init__(self, i2c_bus, address=0x33): + self.i2c_device = I2CDevice(i2c_bus, address) + self._I2CReadWords(0x2400, eeData) + # print(eeData) + self._ExtractParameters() + + @property + def serial_number(self): + """ 3-item tuple of hex values that are unique to each MLX90640 """ + serialWords = [0, 0, 0] + self._I2CReadWords(MLX90640_DEVICEID1, serialWords) + return serialWords + + @property + def refresh_rate(self): + """ How fast the MLX90640 will spit out data. Start at lowest speed in + RefreshRate and then slowly increase I2C clock rate and rate until you + max out. The sensor does not like it if the I2C host cannot 'keep up'!""" + controlRegister = [0] + self._I2CReadWords(0x800D, controlRegister) + return (controlRegister[0] >> 7) & 0x07 + + @refresh_rate.setter + def refresh_rate(self, rate): + controlRegister = [0] + value = (rate & 0x7) << 7 + self._I2CReadWords(0x800D, controlRegister) + value |= controlRegister[0] & 0xFC7F + self._I2CWriteWord(0x800D, value) + + def getFrame(self, framebuf): + """ Request both 'halves' of a frame from the sensor, merge them + and calculate the temperature in C for each of 32x24 pixels. Placed + into the 768-element array passed in! """ + emissivity = 0.95 + tr = 23.15 + mlx90640Frame = [0] * 834 + + for _ in range(2): + status = self._GetFrameData(mlx90640Frame) + if status < 0: + raise RuntimeError("Frame data error") + # For a MLX90640 in the open air the shift is -8 degC. + tr = self._GetTa(mlx90640Frame) - OPENAIR_TA_SHIFT + self._CalculateTo(mlx90640Frame, emissivity, tr, framebuf) + + def _GetFrameData(self, frameData): + dataReady = 0 + cnt = 0 + statusRegister = [0] + controlRegister = [0] + + while dataReady == 0: + self._I2CReadWords(0x8000, statusRegister) + dataReady = statusRegister[0] & 0x0008 + # print("ready status: 0x%x" % dataReady) + + while (dataReady != 0) and (cnt < 5): + self._I2CWriteWord(0x8000, 0x0030) + # print("Read frame", cnt) + self._I2CReadWords(0x0400, frameData, end=832) + + self._I2CReadWords(0x8000, statusRegister) + dataReady = statusRegister[0] & 0x0008 + # print("frame ready: 0x%x" % dataReady) + cnt += 1 + + if cnt > 4: + raise RuntimeError("Too many retries") + + self._I2CReadWords(0x800D, controlRegister) + frameData[832] = controlRegister[0] + frameData[833] = statusRegister[0] & 0x0001 + return frameData[833] + + def _GetTa(self, frameData): + vdd = self._GetVdd(frameData) + + ptat = frameData[800] + if ptat > 32767: + ptat -= 65536 + + ptatArt = frameData[768] + if ptatArt > 32767: + ptatArt -= 65536 + ptatArt = (ptat / (ptat * self.alphaPTAT + ptatArt)) * math.pow(2, 18) + + ta = ptatArt / (1 + self.KvPTAT * (vdd - 3.3)) - self.vPTAT25 + ta = ta / self.KtPTAT + 25 + return ta + + def _GetVdd(self, frameData): + vdd = frameData[810] + if vdd > 32767: + vdd -= 65536 + + resolutionRAM = (frameData[832] & 0x0C00) >> 10 + resolutionCorrection = math.pow(2, self.resolutionEE) / math.pow( + 2, resolutionRAM + ) + vdd = (resolutionCorrection * vdd - self.vdd25) / self.kVdd + 3.3 + + return vdd + + def _CalculateTo(self, frameData, emissivity, tr, result): + # pylint: disable=too-many-locals, too-many-branches, too-many-statements + subPage = frameData[833] + alphaCorrR = [0] * 4 + irDataCP = [0, 0] + + vdd = self._GetVdd(frameData) + ta = self._GetTa(frameData) + + ta4 = ta + 273.15 + ta4 = ta4 * ta4 + ta4 = ta4 * ta4 + tr4 = tr + 273.15 + tr4 = tr4 * tr4 + tr4 = tr4 * tr4 + taTr = tr4 - (tr4 - ta4) / emissivity + + ktaScale = math.pow(2, self.ktaScale) + kvScale = math.pow(2, self.kvScale) + alphaScale = math.pow(2, self.alphaScale) + + alphaCorrR[0] = 1 / (1 + self.ksTo[0] * 40) + alphaCorrR[1] = 1 + alphaCorrR[2] = 1 + self.ksTo[1] * self.ct[2] + alphaCorrR[3] = alphaCorrR[2] * (1 + self.ksTo[2] * (self.ct[3] - self.ct[2])) + + # --------- Gain calculation ----------------------------------- + gain = frameData[778] + if gain > 32767: + gain -= 65536 + gain = self.gainEE / gain + + # --------- To calculation ------------------------------------- + mode = (frameData[832] & 0x1000) >> 5 + + irDataCP[0] = frameData[776] + irDataCP[1] = frameData[808] + for i in range(2): + if irDataCP[i] > 32767: + irDataCP[i] -= 65536 + irDataCP[i] *= gain + + irDataCP[0] -= ( + self.cpOffset[0] + * (1 + self.cpKta * (ta - 25)) + * (1 + self.cpKv * (vdd - 3.3)) + ) + if mode == self.calibrationModeEE: + irDataCP[1] -= ( + self.cpOffset[1] + * (1 + self.cpKta * (ta - 25)) + * (1 + self.cpKv * (vdd - 3.3)) + ) + else: + irDataCP[1] -= ( + (self.cpOffset[1] + self.ilChessC[0]) + * (1 + self.cpKta * (ta - 25)) + * (1 + self.cpKv * (vdd - 3.3)) + ) + + for pixelNumber in range(768): + ilPattern = pixelNumber // 32 - (pixelNumber // 64) * 2 + chessPattern = ilPattern ^ (pixelNumber - (pixelNumber // 2) * 2) + conversionPattern = ( + (pixelNumber + 2) // 4 + - (pixelNumber + 3) // 4 + + (pixelNumber + 1) // 4 + - pixelNumber // 4 + ) * (1 - 2 * ilPattern) + + if mode == 0: + pattern = ilPattern + else: + pattern = chessPattern + + if pattern == frameData[833]: + irData = frameData[pixelNumber] + if irData > 32767: + irData -= 65536 + irData *= gain + + kta = self.kta[pixelNumber] / ktaScale + kv = self.kv[pixelNumber] / kvScale + irData -= ( + self.offset[pixelNumber] + * (1 + kta * (ta - 25)) + * (1 + kv * (vdd - 3.3)) + ) + + if mode != self.calibrationModeEE: + irData += ( + self.ilChessC[2] * (2 * ilPattern - 1) + - self.ilChessC[1] * conversionPattern + ) + + irData = irData - self.tgc * irDataCP[subPage] + irData /= emissivity + + alphaCompensated = SCALEALPHA * alphaScale / self.alpha[pixelNumber] + alphaCompensated *= 1 + self.KsTa * (ta - 25) + + Sx = ( + alphaCompensated + * alphaCompensated + * alphaCompensated + * (irData + alphaCompensated * taTr) + ) + Sx = math.sqrt(math.sqrt(Sx)) * self.ksTo[1] + + To = ( + math.sqrt( + math.sqrt( + irData + / (alphaCompensated * (1 - self.ksTo[1] * 273.15) + Sx) + + taTr + ) + ) + - 273.15 + ) + + if To < self.ct[1]: + torange = 0 + elif To < self.ct[2]: + torange = 1 + elif To < self.ct[3]: + torange = 2 + else: + torange = 3 + + To = ( + math.sqrt( + math.sqrt( + irData + / ( + alphaCompensated + * alphaCorrR[torange] + * (1 + self.ksTo[torange] * (To - self.ct[torange])) + ) + + taTr + ) + ) + - 273.15 + ) + + result[pixelNumber] = To + + # pylint: enable=too-many-locals, too-many-branches, too-many-statements + + def _ExtractParameters(self): + self._ExtractVDDParameters() + self._ExtractPTATParameters() + self._ExtractGainParameters() + self._ExtractTgcParameters() + self._ExtractResolutionParameters() + self._ExtractKsTaParameters() + self._ExtractKsToParameters() + self._ExtractCPParameters() + self._ExtractAlphaParameters() + self._ExtractOffsetParameters() + self._ExtractKtaPixelParameters() + self._ExtractKvPixelParameters() + self._ExtractCILCParameters() + self._ExtractDeviatingPixels() + + def _ExtractVDDParameters(self): + # extract VDD + self.kVdd = (eeData[51] & 0xFF00) >> 8 + if self.kVdd > 127: + self.kVdd -= 256 # convert to signed + self.kVdd *= 32 + self.vdd25 = eeData[51] & 0x00FF + self.vdd25 = ((self.vdd25 - 256) << 5) - 8192 + + def _ExtractPTATParameters(self): + # extract PTAT + self.KvPTAT = (eeData[50] & 0xFC00) >> 10 + if self.KvPTAT > 31: + self.KvPTAT -= 64 + self.KvPTAT /= 4096 + self.KtPTAT = eeData[50] & 0x03FF + if self.KtPTAT > 511: + self.KtPTAT -= 1024 + self.KtPTAT /= 8 + self.vPTAT25 = eeData[49] + self.alphaPTAT = (eeData[16] & 0xF000) / math.pow(2, 14) + 8 + + def _ExtractGainParameters(self): + # extract Gain + self.gainEE = eeData[48] + if self.gainEE > 32767: + self.gainEE -= 65536 + + def _ExtractTgcParameters(self): + # extract Tgc + #print(eeData[60]) + self.tgc = eeData[60] & 0x00FF + #print(self.tgc) + if self.tgc > 127: + self.tgc -= 256 + self.tgc /= 32 + #print(self.tgc) + + def _ExtractResolutionParameters(self): + # extract resolution + self.resolutionEE = (eeData[56] & 0x3000) >> 12 + + def _ExtractKsTaParameters(self): + # extract KsTa + self.KsTa = (eeData[60] & 0xFF00) >> 8 + if self.KsTa > 127: + self.KsTa -= 256 + self.KsTa /= 8192 + + def _ExtractKsToParameters(self): + # extract ksTo + step = ((eeData[63] & 0x3000) >> 12) * 10 + self.ct[0] = -40 + self.ct[1] = 0 + self.ct[2] = (eeData[63] & 0x00F0) >> 4 + self.ct[3] = (eeData[63] & 0x0F00) >> 8 + self.ct[2] *= step + self.ct[3] = self.ct[2] + self.ct[3] * step + + KsToScale = (eeData[63] & 0x000F) + 8 + KsToScale = 1 << KsToScale + + self.ksTo[0] = eeData[61] & 0x00FF + self.ksTo[1] = (eeData[61] & 0xFF00) >> 8 + self.ksTo[2] = eeData[62] & 0x00FF + self.ksTo[3] = (eeData[62] & 0xFF00) >> 8 + + for i in range(4): + if self.ksTo[i] > 127: + self.ksTo[i] -= 256 + self.ksTo[i] /= KsToScale + self.ksTo[4] = -0.0002 + + def _ExtractCPParameters(self): + # extract CP + offsetSP = [0] * 2 + alphaSP = [0] * 2 + + alphaScale = ((eeData[32] & 0xF000) >> 12) + 27 + + offsetSP[0] = eeData[58] & 0x03FF + if offsetSP[0] > 511: + offsetSP[0] -= 1024 + + offsetSP[1] = (eeData[58] & 0xFC00) >> 10 + if offsetSP[1] > 31: + offsetSP[1] -= 64 + offsetSP[1] += offsetSP[0] + + alphaSP[0] = eeData[57] & 0x03FF + if alphaSP[0] > 511: + alphaSP[0] -= 1024 + alphaSP[0] /= math.pow(2, alphaScale) + + alphaSP[1] = (eeData[57] & 0xFC00) >> 10 + if alphaSP[1] > 31: + alphaSP[1] -= 64 + alphaSP[1] = (1 + alphaSP[1] / 128) * alphaSP[0] + + cpKta = eeData[59] & 0x00FF + if cpKta > 127: + cpKta -= 256 + ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8 + self.cpKta = cpKta / math.pow(2, ktaScale1) + + cpKv = (eeData[59] & 0xFF00) >> 8 + if cpKv > 127: + cpKv -= 256 + kvScale = (eeData[56] & 0x0F00) >> 8 + self.cpKv = cpKv / math.pow(2, kvScale) + + self.cpAlpha[0] = alphaSP[0] + self.cpAlpha[1] = alphaSP[1] + self.cpOffset[0] = offsetSP[0] + self.cpOffset[1] = offsetSP[1] + #print(self.cpAlpha[0]) + #print(self.cpAlpha[1]) + + def _ExtractAlphaParameters(self): + # extract alpha + accRemScale = eeData[32] & 0x000F + accColumnScale = (eeData[32] & 0x00F0) >> 4 + accRowScale = (eeData[32] & 0x0F00) >> 8 + alphaScale = ((eeData[32] & 0xF000) >> 12) + 30 + alphaRef = eeData[33] + accRow = [0] * 24 + accColumn = [0] * 32 + alphaTemp = [0] * 768 + + for i in range(6): + p = i * 4 + accRow[p + 0] = eeData[34 + i] & 0x000F + accRow[p + 1] = (eeData[34 + i] & 0x00F0) >> 4 + accRow[p + 2] = (eeData[34 + i] & 0x0F00) >> 8 + accRow[p + 3] = (eeData[34 + i] & 0xF000) >> 12 + + for i in range(24): + if accRow[i] > 7: + accRow[i] -= 16 + + for i in range(8): + p = i * 4 + accColumn[p + 0] = eeData[40 + i] & 0x000F + accColumn[p + 1] = (eeData[40 + i] & 0x00F0) >> 4 + accColumn[p + 2] = (eeData[40 + i] & 0x0F00) >> 8 + accColumn[p + 3] = (eeData[40 + i] & 0xF000) >> 12 + + for i in range(32): + if accColumn[i] > 7: + accColumn[i] -= 16 + for i in range(24): + for j in range(32): + p = 32 * i + j + alphaTemp[p] = (eeData[64 + p] & 0x03F0) >> 4 + if alphaTemp[p] > 31: + alphaTemp[p] -= 64 + alphaTemp[p] *= 1 << accRemScale + alphaTemp[p] += ( + alphaRef + + (accRow[i] << accRowScale) + + (accColumn[j] << accColumnScale) + ) + alphaTemp[p] /= math.pow(2, alphaScale) + alphaTemp[p] -= self.tgc * (self.cpAlpha[0] + self.cpAlpha[1]) / 2 + alphaTemp[p] = SCALEALPHA / alphaTemp[p] + # print("alphaTemp: ", alphaTemp) + + temp = max(alphaTemp) + #print("temp", temp) + + alphaScale = 0 + while temp < 32768: + temp *= 2 + alphaScale += 1 + + for i in range(768): + temp = alphaTemp[i] * math.pow(2, alphaScale) + self.alpha[i] = int(temp + 0.5) + + self.alphaScale = alphaScale + + def _ExtractOffsetParameters(self): + # extract offset + occRow = [0] * 24 + occColumn = [0] * 32 + + occRemScale = eeData[16] & 0x000F + occColumnScale = (eeData[16] & 0x00F0) >> 4 + occRowScale = (eeData[16] & 0x0F00) >> 8 + offsetRef = eeData[17] + if offsetRef > 32767: + offsetRef -= 65536 + + for i in range(6): + p = i * 4 + occRow[p + 0] = eeData[18 + i] & 0x000F + occRow[p + 1] = (eeData[18 + i] & 0x00F0) >> 4 + occRow[p + 2] = (eeData[18 + i] & 0x0F00) >> 8 + occRow[p + 3] = (eeData[18 + i] & 0xF000) >> 12 + + for i in range(24): + if occRow[i] > 7: + occRow[i] -= 16 + + for i in range(8): + p = i * 4 + occColumn[p + 0] = eeData[24 + i] & 0x000F + occColumn[p + 1] = (eeData[24 + i] & 0x00F0) >> 4 + occColumn[p + 2] = (eeData[24 + i] & 0x0F00) >> 8 + occColumn[p + 3] = (eeData[24 + i] & 0xF000) >> 12 + + for i in range(32): + if occColumn[i] > 7: + occColumn[i] -= 16 + + for i in range(24): + for j in range(32): + p = 32 * i + j + self.offset[p] = (eeData[64 + p] & 0xFC00) >> 10 + if self.offset[p] > 31: + self.offset[p] -= 64 + self.offset[p] *= 1 << occRemScale + self.offset[p] += ( + offsetRef + + (occRow[i] << occRowScale) + + (occColumn[j] << occColumnScale) + ) + + def _ExtractKtaPixelParameters(self): # pylint: disable=too-many-locals + # extract KtaPixel + KtaRC = [0] * 4 + ktaTemp = [0] * 768 + + KtaRoCo = (eeData[54] & 0xFF00) >> 8 + if KtaRoCo > 127: + KtaRoCo -= 256 + KtaRC[0] = KtaRoCo + + KtaReCo = eeData[54] & 0x00FF + if KtaReCo > 127: + KtaReCo -= 256 + KtaRC[2] = KtaReCo + + KtaRoCe = (eeData[55] & 0xFF00) >> 8 + if KtaRoCe > 127: + KtaRoCe -= 256 + KtaRC[1] = KtaRoCe + + KtaReCe = eeData[55] & 0x00FF + if KtaReCe > 127: + KtaReCe -= 256 + KtaRC[3] = KtaReCe + + ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8 + ktaScale2 = eeData[56] & 0x000F + + for i in range(24): + for j in range(32): + p = 32 * i + j + split = 2 * (p // 32 - (p // 64) * 2) + p % 2 + ktaTemp[p] = (eeData[64 + p] & 0x000E) >> 1 + if ktaTemp[p] > 3: + ktaTemp[p] -= 8 + ktaTemp[p] *= 1 << ktaScale2 + ktaTemp[p] += KtaRC[split] + ktaTemp[p] /= math.pow(2, ktaScale1) + # ktaTemp[p] = ktaTemp[p] * mlx90640->offset[p]; + + temp = abs(ktaTemp[0]) + for kta in ktaTemp: + temp = max(temp, abs(kta)) + + ktaScale1 = 0 + while temp < 64: + temp *= 2 + ktaScale1 += 1 + + for i in range(768): + temp = ktaTemp[i] * math.pow(2, ktaScale1) + if temp < 0: + self.kta[i] = int(temp - 0.5) + else: + self.kta[i] = int(temp + 0.5) + self.ktaScale = ktaScale1 + + def _ExtractKvPixelParameters(self): + KvT = [0] * 4 + kvTemp = [0] * 768 + + KvRoCo = (eeData[52] & 0xF000) >> 12 + if KvRoCo > 7: + KvRoCo -= 16 + KvT[0] = KvRoCo + + KvReCo = (eeData[52] & 0x0F00) >> 8 + if KvReCo > 7: + KvReCo -= 16 + KvT[2] = KvReCo + + KvRoCe = (eeData[52] & 0x00F0) >> 4 + if KvRoCe > 7: + KvRoCe -= 16 + KvT[1] = KvRoCe + + KvReCe = eeData[52] & 0x000F + if KvReCe > 7: + KvReCe -= 16 + KvT[3] = KvReCe + + kvScale = (eeData[56] & 0x0F00) >> 8 + + for i in range(24): + for j in range(32): + p = 32 * i + j + split = 2 * (p // 32 - (p // 64) * 2) + p % 2 + kvTemp[p] = KvT[split] + kvTemp[p] /= math.pow(2, kvScale) + # kvTemp[p] = kvTemp[p] * mlx90640->offset[p]; + + temp = abs(kvTemp[0]) + for kv in kvTemp: + temp = max(temp, abs(kv)) + + kvScale = 0 + while temp < 64: + temp *= 2 + kvScale += 1 + + for i in range(768): + temp = kvTemp[i] * math.pow(2, kvScale) + if temp < 0: + self.kv[i] = int(temp - 0.5) + else: + self.kv[i] = int(temp + 0.5) + self.kvScale = kvScale + + def _ExtractCILCParameters(self): + ilChessC = [0] * 3 + + self.calibrationModeEE = (eeData[10] & 0x0800) >> 4 + self.calibrationModeEE = self.calibrationModeEE ^ 0x80 + + ilChessC[0] = eeData[53] & 0x003F + if ilChessC[0] > 31: + ilChessC[0] -= 64 + ilChessC[0] /= 16.0 + + ilChessC[1] = (eeData[53] & 0x07C0) >> 6 + if ilChessC[1] > 15: + ilChessC[1] -= 32 + ilChessC[1] /= 2.0 + + ilChessC[2] = (eeData[53] & 0xF800) >> 11 + if ilChessC[2] > 15: + ilChessC[2] -= 32 + ilChessC[2] /= 8.0 + + self.ilChessC = ilChessC + + def _ExtractDeviatingPixels(self): + self.brokenPixels = [0xFFFF] * 5 + self.outlierPixels = [0xFFFF] * 5 + + pixCnt = 0 + brokenPixCnt = 0 + outlierPixCnt = 0 + + while (pixCnt < 768) and (brokenPixCnt < 5) and (outlierPixCnt < 5): + if eeData[pixCnt + 64] == 0: + self.brokenPixels[brokenPixCnt] = pixCnt + brokenPixCnt += 1 + elif (eeData[pixCnt + 64] & 0x0001) != 0: + self.outlierPixels[outlierPixCnt] = pixCnt + outlierPixCnt += 1 + pixCnt += 1 + + if brokenPixCnt > 4: + raise RuntimeError("More than 4 broken pixels") + if outlierPixCnt > 4: + raise RuntimeError("More than 4 outlier pixels") + if (brokenPixCnt + outlierPixCnt) > 4: + raise RuntimeError("More than 4 faulty pixels") + # print("Found %d broken pixels, %d outliers" % (brokenPixCnt, outlierPixCnt)) + # TODO INCOMPLETE + + def _I2CWriteWord(self, writeAddress, data): + cmd = bytearray(4) + cmd[0] = writeAddress >> 8 + cmd[1] = writeAddress & 0x00FF + cmd[2] = data >> 8 + cmd[3] = data & 0x00FF + dataCheck = [0] + + with self.i2c_device as i2c: + i2c.write(cmd) + # print("Wrote:", [hex(i) for i in cmd]) + time.sleep(0.001) + self._I2CReadWords(writeAddress, dataCheck) + # print("dataCheck: 0x%x" % dataCheck[0]) + # if (dataCheck != data): + # return -2 + + _inbuf = bytearray(2 * I2C_READ_LEN) + + def _I2CReadWords(self, addr, buffer, *, end=None): + # stamp = time.monotonic() + if end is None: + remainingWords = len(buffer) + else: + remainingWords = end + offset = 0 + addrbuf = bytearray(2) + # inbuf = bytearray(2 * I2C_READ_LEN) + inbuf = self._inbuf + + with self.i2c_device as i2c: + while remainingWords: + addrbuf[0] = addr >> 8 # MSB + addrbuf[1] = addr & 0xFF # LSB + read_words = min(remainingWords, I2C_READ_LEN) + i2c.write_then_readinto( + addrbuf, inbuf, in_end=read_words * 2 + ) # in bytes + # print("-> ", [hex(i) for i in addrbuf]) + + outwords = struct.unpack( + ">" + "H" * read_words, inbuf[0 : read_words * 2] + ) + # print("<- (", read_words, ")", [hex(i) for i in outwords]) + for i, w in enumerate(outwords): + buffer[offset + i] = w + offset += read_words + remainingWords -= read_words + addr += read_words + +ixc = None +mlx = None +frame = None + +def init_camera(scl_pin=22, sda_pin=21, freq=100000): + """Explicitly initializes the I2C bus and camera after power is stable.""" + global ixc, mlx, frame + + print(f"[Camera] Initializing I2C on SCL:{scl_pin}, SDA:{sda_pin} at {freq}Hz...") + ixc = I2C(pins=(scl_pin, sda_pin), frequency=freq) + + print("[Camera] Probing for MLX90640...") + mlx = MLX90640(ixc) + + # Bonus: Your wiki snapshot recommends 16Hz for smooth images! + mlx.refresh_rate = RefreshRate.REFRESH_16_HZ + + frame = [0] * 768 + print("[Camera] Setup successful!") + +def read_temperature(): + if mlx is None: + print("[Camera] Error: Camera not initialized. Call init_camera() first.") + return None + try: + print("Querying camera...") + mlx.getFrame(frame) + return frame + except Exception as e: + print(f"[Camera] Read error: {e}") + return None diff --git a/orchestrateur/main.py b/orchestrateur/main.py index 54ee21a..36b29a7 100644 --- a/orchestrateur/main.py +++ b/orchestrateur/main.py @@ -1,7 +1,12 @@ +import json import threading import queue import time -from shared import get_lora, get_mqtt_client, deviceTypes, config +import traceback +from orchestrateur.sensors import gps +from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads +from shared.logging import log +from sensors import ultrasonicRanger, temp_hum, button, camera # --- Read Unique Device ID --- try: @@ -49,6 +54,7 @@ mqtt_client = get_mqtt_client( ) mqtt_client.connect() mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) +mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}") # --- THE CRUCIAL PAHO FIX --- @@ -66,51 +72,122 @@ def mqtt_listener(): message = mqtt_client.get_message() if message: + # Try to parse the payload as a python dictionary, but if it fails, just print the raw payload + try: + payload = json.loads(message['payload']) + except Exception as e: + print(f"Error parsing MQTT payload: {e}") + payload = message['payload'] # Fallback to raw payload if parsing fails + print(f"\n[Thread MQTT] Message reçu : {message}") - data_queue.put({"source": "MQTT", "data": message}) + data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload}) - # --- THE CPU FIX --- # Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog. - time.sleep(0.1) + time.sleep(0.2) +# Button +button_state = False +def button_callback(): + global button_state + button_state = not button_state + print(f"\n[Thread Button] Button state changed to: {button_state}") + +button.set_callback(button_callback) # Launch background monitoring workers -threading.Thread(target=lora_listener, daemon=True).start() -threading.Thread(target=mqtt_listener, daemon=True).start() +# threading.Thread(target=lora_listener, daemon=True).start() +# threading.Thread(target=mqtt_listener, daemon=True).start() +# Launch button monitoring thread +button.start_button_monitoring_thread() print("Orchestrateur prêt. Le main loop est libre.") +# Sensor reading +def read_sensors(): + """Read all sensors and return a dictionary of their values.""" + log("\nLecture des capteurs...") + sensor_data = {} + + # Read Ultrasonic Ranger + distance = ultrasonicRanger.get_dish_height() + if distance is not None: + log(f"\nLecture du capteur Ultrason : {distance}") + sensor_data["ultrasonic_distance"] = distance + + # Read Temperature and Humidity + temperature, humidity = temp_hum.get_temperature_and_humidity() + if temperature is not None and humidity is not None: + log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}") + sensor_data["temperature"] = temperature + sensor_data["humidity"] = humidity + + + # Read GPS Data + gps_data = gps.get_gps_data() + if gps_data: + log(f"\nLecture du capteur GPS : {gps_data}") + sensor_data["gps"] = gps_data + + # Camera + picture_bytes = None + try: + picture_bytes = camera.get_picture() + log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes") + sensor_data["camera_image"] = picture_bytes + except Exception as e: + log(f"Error reading camera data: {e}") + + # Read Button State (last because he can still change state while reading other sensors) + sensor_data["button_state"] = button_state + + return sensor_data + # --- MAIN EXECUTION LOOP --- while True: try: - # Check for non-heartbeat data safely + # Check for non-heartbeat data try: msg = data_queue.get(block=False) - print(f"\n[Main Loop] Données traitées : {msg['data']}") + + # print(msg) + + if msg["source"] == "LoRa": + print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}") + elif msg["source"] == "MQTT": + if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')): + if ("id_orchestrator" in msg["data"] and msg["data"]["id_orchestrator"] == DEVICE_ID): + # Do not answer to messages coming from me + continue + microwave_id = msg["data"]["id_microwave"] + print(f"\n[Main Loop] MQTT : Hello reçu de {microwave_id}.") + # Responds + mqtt_client.publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, microwave_id), qos=config.MQTT_QOS) + print(f"[Main Loop] MQTT : Réponse Hello envoyée à {microwave_id}.") + # TODO : Save in database + + + print(f"\n[Main Loop] MQTT : Données traitées : {msg['data']}") except queue.Empty: pass + + # DEBUG : Read sensors + sensor_values = read_sensors() + if sensor_values: + sensor_values_print = sensor_values.copy() + if "camera_image" in sensor_values_print: + sensor_values_print["camera_image"] = f"<{len(sensor_values_print['camera_image'])} bytes>" + print(f"\nCapteurs Données lues : {sensor_values_print}") - time.sleep(1) - - # Publish debug telemetry message - print("[Main Loop] Envoi d'un message de debug sur MQTT...") - response = mqtt_client.publish( - config.MQTT_TOPIC_COOKING, - f"Orchestrateur actif, ID: {DEVICE_ID}", - qos=config.MQTT_QOS - ) - - # This will now unblock instantly because loop_start() handles the delivery confirmation! - response.wait_for_publish() - print("[Main Loop] Message de debug publié avec succès.") - - time.sleep(9) + time.sleep(3) except KeyboardInterrupt: break + except Exception as e: + traceback.print_exc() + time.sleep(1) # Prevents rapid error logging in case of persistent issues # Clean termination if hasattr(mqtt_client._client, "loop_stop"): mqtt_client._client.loop_stop() -mqtt_client.close() \ No newline at end of file +mqtt_client.close() diff --git a/orchestrateur/requirements.txt b/orchestrateur/requirements.txt index d1e4b52..51764b9 100644 --- a/orchestrateur/requirements.txt +++ b/orchestrateur/requirements.txt @@ -1,2 +1,6 @@ paho-mqtt>=1.6,<3 -pyserial>=3.5,<4 \ No newline at end of file +pyserial>=3.5,<4 +# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2 +# OpenCV +# sudo apt install -y python3-opencv +# sudo apt install -y opencv-data \ No newline at end of file diff --git a/orchestrateur/sensors/__init__.py b/orchestrateur/sensors/__init__.py new file mode 100644 index 0000000..de12db0 --- /dev/null +++ b/orchestrateur/sensors/__init__.py @@ -0,0 +1,7 @@ +# import grovepi + +import sensors.ultrasonicRanger as ultrasonicRanger +import sensors.temp_hum as temp_hum +import sensors.button as button +import sensors.gps as gps +import sensors.camera as camera \ No newline at end of file diff --git a/orchestrateur/sensors/button.py b/orchestrateur/sensors/button.py new file mode 100644 index 0000000..703b882 --- /dev/null +++ b/orchestrateur/sensors/button.py @@ -0,0 +1,44 @@ +import grovepi +import time +import threading +from sensors.lock import grove_lock +from shared.logging import log + +button = 2 +button_switch_state = 0 +grovepi.pinMode(button, "INPUT") + +button_callback = None + +def read_button_state(): + if not grove_lock.acquire(timeout=0.05): + return None + try: + return grovepi.digitalRead(button) + except Exception as e: + log(f"BTN Error: {e}") + return None + finally: + grove_lock.release() + +def monitor_button(): + global button_switch_state + last_button_state = button_switch_state + + while True: + time.sleep(0.04) + + current_state = read_button_state() + + if current_state is not None: + if current_state == 1 and last_button_state == 0: + if button_callback: + button_callback() + last_button_state = current_state + +def start_button_monitoring_thread(): + threading.Thread(target=monitor_button, daemon=True).start() + +def set_callback(callback): + global button_callback + button_callback = callback \ No newline at end of file diff --git a/orchestrateur/sensors/camera.py b/orchestrateur/sensors/camera.py new file mode 100644 index 0000000..2ee7901 --- /dev/null +++ b/orchestrateur/sensors/camera.py @@ -0,0 +1,33 @@ +import grovepi +import math +from sensors.lock import grove_lock +from picamera2 import Picamera2, Preview +import time + +picam2 = Picamera2() + +camera_config = picam2.create_still_configuration() +picam2.configure(camera_config) + +picam2.start() +time.sleep(2) + +def preview_camera(): + picam2.start_preview(Preview.DRM) + +def stop_preview_camera(): + picam2.stop_preview() + +def take_picture(): + """Takes a picture and saves it to the file system""" + picam2.capture_file("test.jpg") + return "test.jpg" + +def get_picture(): + """Returns the image bytes as base64 + """ + file_path = take_picture() + with open(file_path, "rb") as f: + image_bytes = f.read() + return image_bytes + diff --git a/orchestrateur/sensors/gps.py b/orchestrateur/sensors/gps.py new file mode 100644 index 0000000..577afb2 --- /dev/null +++ b/orchestrateur/sensors/gps.py @@ -0,0 +1,122 @@ +import serial +import time +import threading +from shared.logging import log +from sensors.lock import serial_lock + +def calculate_nmea_checksum(line: str) -> bool: + """Validates standard NMEA 0183 sentence checksum ($...*HH).""" + if not line.startswith('$') or '*' not in line: + return False + + try: + content, checksum_str = line[1:].split('*', 1) + calculated_checksum = 0 + for char in content: + calculated_checksum ^= ord(char) + + return calculated_checksum == int(checksum_str[:2], 16) + except Exception: + return False + + +class GROVEGPS: + def __init__(self, port='/dev/ttyAMA0', baud=9600, timeout=1): + self.ser = serial.Serial(port, baud, timeout=timeout) + self.clean_data() + + def clean_data(self): + self.timestamp = "" + self.quality = 0 + self.satellites = 0 + self.altitude = -1.0 + self.latitude = -1.0 + self.longitude = -1.0 + + def read(self): + """Reads the latest GGA sentence from serial, thread-safely.""" + with serial_lock: + # 1. Flush accumulated stale data in the UART buffer + if self.ser.in_waiting > 0: + self.ser.reset_input_buffer() + + # 2. Try reading up to 15 lines to catch the freshest GGA sentence + for _ in range(5): + raw_bytes = self.ser.readline() + try: + line = raw_bytes.decode('utf-8', errors='ignore').strip() + # log(f"GPS: Read line: {line}") + except Exception: + continue + + # Supports both $GPGGA and modern $GNGGA sentences + if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line): + if self.parse_gga(line): + return True + return False + + def parse_gga(self, line): + self.clean_data() + gga = line.split(',') + + if len(gga) < 10: + return False + + try: + self.timestamp = gga[1] + self.quality = int(gga[6]) if gga[6] != "" else 0 + self.satellites = int(gga[7]) if gga[7] != "" else 0 + + # If quality > 0 and coordinates exist, convert NMEA DDDMM.MMMM to decimal degrees + if self.quality > 0 and gga[2] != "" and gga[4] != "": + lat_raw = float(gga[2]) + ns = gga[3] + lon_raw = float(gga[4]) + ew = gga[5] + + # Latitude calculation + lat_deg = lat_raw // 100 + lat_min = lat_raw % 100 + self.latitude = lat_deg + (lat_min / 60.0) + if ns == 'S': + self.latitude = -self.latitude + + # Longitude calculation + lon_deg = lon_raw // 100 + lon_min = lon_raw % 100 + self.longitude = lon_deg + (lon_min / 60.0) + if ew == 'W': + self.longitude = -self.longitude + + self.altitude = float(gga[9]) if gga[9] != "" else -1.0 + return True + else: + # No lock on this line + return True + + except (ValueError, IndexError): + return False + + +# Shared instance +gps = GROVEGPS() + +def get_gps_data(): + """Returns GPS dictionary if fix is valid, otherwise returns None.""" + has_data = gps.read() + + # Strictly check that we have a valid GPS lock (quality > 0 and valid coordinates) + if has_data and gps.quality > 0 and gps.latitude != -1.0: + return { + "timestamp": gps.timestamp, + "latitude": round(gps.latitude, 6), + "longitude": round(gps.longitude, 6), + "altitude": gps.altitude, + "quality": gps.quality, + "satellites": gps.satellites + } + else: + log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}") + + # Return None so main.py doesn't process or log empty GPS data + return None \ No newline at end of file diff --git a/orchestrateur/sensors/lib/__init__.py b/orchestrateur/sensors/lib/__init__.py new file mode 100644 index 0000000..0b32fe2 --- /dev/null +++ b/orchestrateur/sensors/lib/__init__.py @@ -0,0 +1,2 @@ +# import orchestrateur.sensors.lib.grovepi_old as grovepi_old +# import sensors.lib.grove_i2c_temp_hum_mini as grove_i2c_temp_hum_mini \ No newline at end of file diff --git a/orchestrateur/sensors/lib/grove_i2c_temp_hum_mini.py b/orchestrateur/sensors/lib/grove_i2c_temp_hum_mini.py new file mode 100644 index 0000000..068a761 --- /dev/null +++ b/orchestrateur/sensors/lib/grove_i2c_temp_hum_mini.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# +# GrovePi Library for using the Grove - Temperature&Humidity Sensor (http://www.seeedstudio.com/depot/Grove-TemperatureHumidity-Sensor-HighAccuracy-Mini-p-1921.html) +# +# The GrovePi connects the Raspberry Pi and Grove sensors. You can learn more about GrovePi here: http://www.dexterindustries.com/GrovePi +# +# Have a question about this library? Ask on the forums here: http://forum.dexterindustries.com/c/grovepi +# +# Released under the MIT license (http://choosealicense.com/licenses/mit/). +# For more information see https://github.com/DexterInd/GrovePi/blob/master/LICENSE +################################################################################################################################################# +# NOTE: +# The software for this sensor is still in development and might make your GrovePi unuable as long as this sensor is connected with the GrovePi +################################################################################################################################################# +import time,sys +import RPi.GPIO as GPIO +import smbus + +debug = 0 +# use the bus that matches your raspi version +rev = GPIO.RPI_REVISION +if rev == 2 or rev == 3: + bus = smbus.SMBus(1) +else: + bus = smbus.SMBus(0) + +class th02: + + ADDRESS = 0x40 + + TH02_REG_STATUS = 0x00 + TH02_REG_DATA_H = 0x01 + TH02_REG_DATA_L = 0x02 + TH02_REG_CONFIG = 0x03 + TH02_REG_ID = 0x11 + + TH02_STATUS_RDY_MASK = 0x01 + + TH02_CMD_MEASURE_HUMI = [0x01] + TH02_CMD_MEASURE_TEMP = [0x11] + + SUCCESS = 0 + + def getTemperature(self): + bus.write_i2c_block_data(self.ADDRESS, self.TH02_REG_CONFIG, self.TH02_CMD_MEASURE_TEMP) + + while 1: + status=self.getStatus() + if debug: + print("st:",status) + if status: + break + t_raw=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_DATA_H,3) + if debug: + print(t_raw) + temperature = (t_raw[1]<<8|t_raw[2])>>2 + return (temperature/32.0)-50.0 + + def getHumidity(self): + bus.write_i2c_block_data(self.ADDRESS, self.TH02_REG_CONFIG, self.TH02_CMD_MEASURE_HUMI) + + while 1: + status=self.getStatus() + if debug: + print("st:",status) + if status: + break + t_raw=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_DATA_H,3) + if debug: + print(t_raw) + temperature = (t_raw[1]<<8|t_raw[2])>>4 + return (temperature/16.0)-24.0 + + def getStatus(self): + status=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_STATUS,1) + if debug: + print(status) + if status[0] & self.TH02_STATUS_RDY_MASK != 1: + return 1 + else: + return 0 + +if __name__ == "__main__": + t= th02() + while True: + print(t.getTemperature(),t.getHumidity()) + time.sleep(.5) \ No newline at end of file diff --git a/orchestrateur/sensors/lib/grovepi_old.py b/orchestrateur/sensors/lib/grovepi_old.py new file mode 100644 index 0000000..2a7a703 --- /dev/null +++ b/orchestrateur/sensors/lib/grovepi_old.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python +# +# GrovePi Python library +# v1.4 +# +# This file provides the basic functions for using the GrovePi +# +# The GrovePi connects the Raspberry Pi and Grove sensors. You can learn more about GrovePi here: http://www.dexterindustries.com/GrovePi +# +# Have a question about this example? Ask on the forums here: http://forum.dexterindustries.com/c/grovepi +# +''' +## License + +The MIT License (MIT) + +GrovePi for the Raspberry Pi: an open source platform for connecting Grove Sensors to the Raspberry Pi. +Copyright (C) 2017 Dexter Industries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +''' +# Initial Date: 13 Feb 2014 +# Last Updated: 11 Nov 2016 +# http://www.dexterindustries.com/ +# Author Date Comments +# Karan 13 Feb 2014 Initial Authoring +# 11 Nov 2016 I2C retries added for faster IO +# DHT function updated to look for nan's + +__version__ = '1.4.1' + +import sys +import time +import math +import struct +import numpy + +import di_i2c + +def set_bus(bus): + global i2c + i2c = di_i2c.DI_I2C(bus = bus, address = address) + +address = 0x04 +max_recv_size = 10 +set_bus("RPI_1SW") + +if sys.version_info<(3,0): + p_version = 2 +else: + p_version = 3 + +# Earliest version of the firmware to work with +works_with_firmware = [ + "1.4.0" +] + +# interrupt operations +COUNT_CHANGES = 0 +COUNT_LOW_DURATION = 1 + +# interrupt trigger mode +CHANGE = 1 +FALLING = 2 +RISING = 3 + +# This allows us to be more specific about which commands contain unused bytes +unused = 0 +retries = 10 +additional_waiting = 0 + +# Get firmware version +version_cmd = [8] +# No data is available from the GrovePi +data_not_available_cmd = [23] + +# Command Format +# digitalRead() command format header +dRead_cmd = [1] +# digitalWrite() command format header +dWrite_cmd = [2] +# analogRead() command format header +aRead_cmd = [3] +# analogWrite() command format header +aWrite_cmd = [4] +# pinMode() command format header +pMode_cmd = [5] +# Ultrasonic read +uRead_cmd = [7] +# Accelerometer (+/- 1.5g) read +acc_xyz_cmd = [20] +# RTC get time +rtc_getTime_cmd = [30] +# DHT Pro sensor temperature +dht_temp_cmd = [40] + +# Grove LED Bar commands +# Initialise +ledBarInit_cmd = [50] +# Set orientation +ledBarOrient_cmd = [51] +# Set level +ledBarLevel_cmd = [52] +# Set single LED +ledBarSetOne_cmd = [53] +# Toggle single LED +ledBarToggleOne_cmd = [54] +# Set all LEDs +ledBarSet_cmd = [55] +# Get current state +ledBarGet_cmd = [56] + +# Grove 4 Digit Display commands +# Initialise +fourDigitInit_cmd = [70] +# Set brightness, not visible until next cmd +fourDigitBrightness_cmd = [71] +# Set numeric value without leading zeros +fourDigitValue_cmd = [72] +# Set numeric value with leading zeros +fourDigitValueZeros_cmd = [73] +# Set individual digit +fourDigitIndividualDigit_cmd = [74] +# Set individual leds of a segment +fourDigitIndividualLeds_cmd = [75] +# Set left and right values with colon +fourDigitScore_cmd = [76] +# Analog read for n seconds +fourDigitAnalogRead_cmd = [77] +# Entire display on +fourDigitAllOn_cmd = [78] +# Entire display off +fourDigitAllOff_cmd = [79] + +# Grove Chainable RGB LED commands +# Store color for later use +storeColor_cmd = [90] +# Initialise +chainableRgbLedInit_cmd = [91] +# Initialise and test with a simple color +chainableRgbLedTest_cmd = [92] +# Set one or more leds to the stored color by pattern +chainableRgbLedSetPattern_cmd = [93] +# set one or more leds to the stored color by modulo +chainableRgbLedSetModulo_cmd = [94] +# sets leds similar to a bar graph, reversible +chainableRgbLedSetLevel_cmd = [95] + +# Read the button from IR sensor +ir_read_cmd = [21] +# Set pin for the IR receiver +ir_recv_pin_cmd = [22] +# Check if there's data coming from the IR receiver +ir_read_isdata = [24] + +# Interrupt-based devices +isr_set_cmd = [6] +isr_unset_cmd = [9] +isr_read_cmd = [10] +isr_clear_cmd = [11] +isr_active_cmd = [12] + +# Grove Encoders +encoder_read_cmd = [13] +encoder_en_cmd = [14] +encoder_dis_cmd = [15] + +# Dust, Encoder & Flow Sensor commands +# dust_sensor_read_cmd=[10] +# dust_sensor_en_cmd=[14] +# dust_sensor_dis_cmd=[15] +# dust_sensor_int_cmd=[9] +# dust_sensor_read_int_cmd=[6] +# flow_read_cmd=[12] +# flow_disable_cmd=[13] +# flow_en_cmd=[18] + + +# Function declarations of the various functions used for encoding and sending +# data from RPi to Arduino + +# Write I2C block to the GrovePi +def write_i2c_block(block, custom_timing = None): + ''' + Now catches and raises Keyboard Interrupt that the user is responsible to catch. + ''' + counter = 0 + reg = block[0] + data = block[1:] + while counter < 3: + try: + i2c.write_reg_list(reg, data) + time.sleep(0.002 + additional_waiting) + return + except KeyboardInterrupt: + raise KeyboardInterrupt + except: + counter += 1 + time.sleep(0.003) + continue + +# Read I2C block from the GrovePi +def read_i2c_block(no_bytes = max_recv_size): + ''' + Now catches and raises Keyboard Interrupt that the user is responsible to catch. + ''' + data = data_not_available_cmd + counter = 0 + while data[0] in [data_not_available_cmd[0], 255] and counter < 3: + try: + data = i2c.read_list(reg = None, len = no_bytes) + time.sleep(0.002 + additional_waiting) + if counter > 0: + counter = 0 + except KeyboardInterrupt: + raise KeyboardInterrupt + except: + counter += 1 + time.sleep(0.003) + + return data + +def read_identified_i2c_block(read_command_id, no_bytes): + data = [-1] + while len(data) <= 1: + data = read_i2c_block(no_bytes + 1) + + return data[1:] + +# Arduino Digital Read +def digitalRead(pin): + write_i2c_block(dRead_cmd + [pin, unused, unused]) + data = read_identified_i2c_block( dRead_cmd, no_bytes = 1)[0] + return data + +# Arduino Digital Write +def digitalWrite(pin, value): + write_i2c_block(dWrite_cmd + [pin, value, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Read analog value from Pin +def analogRead(pin): + write_i2c_block(aRead_cmd + [pin, unused, unused]) + number = read_identified_i2c_block(aRead_cmd, no_bytes = 2) + return number[0] * 256 + number[1] + + +# Write PWM +def analogWrite(pin, value): + write_i2c_block(aWrite_cmd + [pin, value, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Setting Up Pin mode on Arduino +def pinMode(pin, mode): + if mode == "OUTPUT": + write_i2c_block(pMode_cmd + [pin, 1, unused]) + elif mode == "INPUT": + write_i2c_block(pMode_cmd + [pin, 0, unused]) + read_i2c_block(no_bytes = 1) + return 1 + + +# Read temp in Celsius from Grove Temperature Sensor +def temp(pin, model = '1.0'): + # each of the sensor revisions use different thermistors, each with their own B value constant + if model == '1.2': + bValue = 4250 # sensor v1.2 uses thermistor ??? (assuming NCP18WF104F03RC until SeeedStudio clarifies) + elif model == '1.1': + bValue = 4250 # sensor v1.1 uses thermistor NCP18WF104F03RC + else: + bValue = 3975 # sensor v1.0 uses thermistor TTC3A103*39H + a = analogRead(pin) + resistance = (float)(1023 - a) * 10000 / a + t = (float)(1 / (math.log(resistance / 10000) / bValue + 1 / 298.15) - 273.15) + return t + + +# Read value from Grove Ultrasonic +def ultrasonicRead(pin): + write_i2c_block(uRead_cmd + [pin, unused, unused]) + number = read_identified_i2c_block(uRead_cmd, no_bytes = 2) + return (number[0] * 256 + number[1]) + + +# Read the firmware version +def version(): + write_i2c_block(version_cmd + [unused, unused, unused]) + number = read_identified_i2c_block(version_cmd, no_bytes = 3) + return "%s.%s.%s" % (number[0], number[1], number[2]) + + +# Read Grove Accelerometer (+/- 1.5g) XYZ value +# Need to investigate why this reports what was read with the previous command +# Doesn't look to be implemented on the GrovePi +def acc_xyz(): + write_i2c_block(acc_xyz_cmd + [unused, unused, unused]) + number = read_identified_i2c_block(acc_xyz_cmd, no_bytes = 3) + if number[1] > 32: + number[1] = - (number[1] - 224) + if number[2] > 32: + number[2] = - (number[2] - 224) + if number[3] > 32: + number[3] = - (number[3] - 224) + return (number[0], number[1], number[2]) + + +# Read from Grove RTC +# Doesn't look to be implemented on the GrovePi +def rtc_getTime(): + write_i2c_block(rtc_getTime_cmd + [unused, unused, unused]) + number = read_i2c_block() + return number + +# Read and return temperature and humidity from Grove DHT Pro +def dht(pin, module_type): + write_i2c_block(dht_temp_cmd + [pin, module_type, unused]) + number = read_identified_i2c_block(dht_temp_cmd, no_bytes = 8) + + if p_version==2: + h='' + for element in (number[0:4]): + h+=chr(element) + + t_val=struct.unpack('f', h) + t = round(t_val[0], 2) + + h = '' + for element in (number[4:8]): + h+=chr(element) + + hum_val=struct.unpack('f',h) + hum = round(hum_val[0], 2) + else: + t_val=bytearray(number[0:4]) + h_val=bytearray(number[4:8]) + t=round(struct.unpack('f',t_val)[0],2) + hum=round(struct.unpack('f',h_val)[0],2) + if t > -100.0 and t <150.0 and hum >= 0.0 and hum<=100.0: + return [t, hum] + else: + return [float('nan'),float('nan')] + +# Grove - Infrared Receiver - get the commands received from the Grove IR sensor +def ir_read_signal(): + write_i2c_block(ir_read_cmd + [unused, unused, unused]) + data_back = read_identified_i2c_block(ir_read_cmd, no_bytes = 7) + + return (data_back[0], + data_back[1] + data_back[2] * 256, + data_back[3] + data_back[4] * 256 + data_back[5] * (256 ** 2) + data_back[6] * (256 ** 3)) + +# Grove - Infrared Receiver - set the pin on which the Grove IR sensor is connected +def ir_recv_pin(pin): + write_i2c_block(ir_recv_pin_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + +# Grove - Infrared Receiver - check if there's any data that hasn't been read so far +def ir_is_data(): + write_i2c_block(ir_read_isdata + 3 * [unused]) + number = read_identified_i2c_block(ir_read_isdata, no_bytes = 1) + + return number[0] != 0 + +# after a list of numerical values is provided +# the function returns a list with the outlier(or extreme) values removed +# make the std_factor_threshold bigger so that filtering becomes less strict +# and make the std_factor_threshold smaller to get the opposite +def statisticalNoiseReduction(values, std_factor_threshold = 2): + if len(values) == 0: + return [] + + mean = numpy.mean(values) + standard_deviation = numpy.std(values) + + if standard_deviation == 0: + return values + + filtered_values = [element for element in values if element > mean - std_factor_threshold * standard_deviation] + filtered_values = [element for element in filtered_values if element < mean + std_factor_threshold * standard_deviation] + + return filtered_values + + +# Grove LED Bar - initialise +# orientation: (0 = red to green, 1 = green to red) +def ledBar_init(pin, orientation): + write_i2c_block(ledBarInit_cmd + [pin, orientation, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - set orientation +# orientation: (0 = red to green, 1 = green to red) +def ledBar_orientation(pin, orientation): + write_i2c_block(ledBarOrient_cmd + [pin, orientation, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - set level +# level: (0-10) +def ledBar_setLevel(pin, level): + write_i2c_block(ledBarLevel_cmd + [pin, level, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - set single led +# led: which led (1-10) +# state: off or on (0-1) +def ledBar_setLed(pin, led, state): + write_i2c_block(ledBarSetOne_cmd + [pin, led, state]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - toggle single led +# led: which led (1-10) +def ledBar_toggleLed(pin, led): + write_i2c_block(ledBarToggleOne_cmd + [pin, led, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - set all leds +# state: (0-1023) or (0x00-0x3FF) or (0b0000000000-0b1111111111) or (int('0000000000',2)-int('1111111111',2)) +def ledBar_setBits(pin, state): + byte1 = state & 255 + byte2 = state >> 8 + write_i2c_block(ledBarSet_cmd + [pin, byte1, byte2]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove LED Bar - get current state +# state: (0-1023) a bit for each of the 10 LEDs +def ledBar_getBits(pin): + write_i2c_block(ledBarGet_cmd + [pin, unused, unused]) + block = read_identified_i2c_block(ledBarGet_cmd, no_bytes = 2) + return block[0] ^ (block[1] << 8) + + +# Grove 4 Digit Display - initialise +def fourDigit_init(pin): + write_i2c_block(fourDigitInit_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - set numeric value with or without leading zeros +# value: (0-65535) or (0000-FFFF) +def fourDigit_number(pin, value, leading_zero): + # split the value into two bytes so we can render 0000-FFFF on the display + byte1 = value & 255 + byte2 = value >> 8 + # separate commands to overcome current 4 bytes per command limitation + if (leading_zero): + write_i2c_block(fourDigitValue_cmd + [pin, byte1, byte2]) + else: + write_i2c_block(fourDigitValueZeros_cmd + [pin, byte1, byte2]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - set brightness +# brightness: (0-7) +def fourDigit_brightness(pin, brightness): + # not actually visible until next command is executed + write_i2c_block(fourDigitBrightness_cmd + [pin, brightness, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - set individual segment (0-9,A-F) +# segment: (0-3) +# value: (0-15) or (0-F) +def fourDigit_digit(pin, segment, value): + write_i2c_block(fourDigitIndividualDigit_cmd + [pin, segment, value]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - set 7 individual leds of a segment +# segment: (0-3) +# leds: (0-255) or (0-0xFF) one bit per led, segment 2 is special, 8th bit is the colon +def fourDigit_segment(pin, segment, leds): + write_i2c_block(fourDigitIndividualLeds_cmd + [pin, segment, leds]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - set left and right values (0-99), with leading zeros and a colon +# left: (0-255) or (0-FF) +# right: (0-255) or (0-FF) +# colon will be lit +def fourDigit_score(pin, left, right): + write_i2c_block(fourDigitScore_cmd + [pin, left, right]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - display analogRead value for n seconds, 4 samples per second +# analog: analog pin to read +# duration: analog read for this many seconds +def fourDigit_monitor(pin, analog, duration): + write_i2c_block(fourDigitAnalogRead_cmd + [pin, analog, duration]) + read_i2c_block(no_bytes = 1) + time.sleep(duration) + return 1 + +# Grove 4 Digit Display - turn entire display on (88:88) +def fourDigit_on(pin): + write_i2c_block(fourDigitAllOn_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove 4 Digit Display - turn entire display off +def fourDigit_off(pin): + write_i2c_block(fourDigitAllOff_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - store a color for later use +# red: 0-255 +# green: 0-255 +# blue: 0-255 +def storeColor(red, green, blue): + write_i2c_block(storeColor_cmd + [red, green, blue]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - initialise +# numLeds: how many leds do you have in the chain +def chainableRgbLed_init(pin, numLeds): + write_i2c_block(chainableRgbLedInit_cmd + [pin, numLeds, unused]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - initialise and test with a simple color +# numLeds: how many leds do you have in the chain +# testColor: (0-7) 3 bits in total - a bit for red, green and blue, eg. 0x04 == 0b100 (0bRGB) == rgb(255, 0, 0) == #FF0000 == red +# ie. 0 black, 1 blue, 2 green, 3 cyan, 4 red, 5 magenta, 6 yellow, 7 white +def chainableRgbLed_test(pin, numLeds, testColor): + write_i2c_block(chainableRgbLedTest_cmd + [pin, numLeds, testColor]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - set one or more leds to the stored color by pattern +# pattern: (0-3) 0 = this led only, 1 all leds except this led, 2 this led and all leds inwards, 3 this led and all leds outwards +# whichLed: index of led you wish to set counting outwards from the GrovePi, 0 = led closest to the GrovePi +def chainableRgbLed_pattern(pin, pattern, whichLed): + write_i2c_block(chainableRgbLedSetPattern_cmd + [pin, pattern, whichLed]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - set one or more leds to the stored color by modulo +# offset: index of led you wish to start at, 0 = led closest to the GrovePi, counting outwards +# divisor: when 1 (default) sets stored color on all leds >= offset, when 2 sets every 2nd led >= offset and so on +def chainableRgbLed_modulo(pin, offset, divisor): + write_i2c_block(chainableRgbLedSetModulo_cmd + [pin, offset, divisor]) + read_i2c_block(no_bytes = 1) + return 1 + +# Grove Chainable RGB LED - sets leds similar to a bar graph, reversible +# level: (0-10) the number of leds you wish to set to the stored color +# reversible (0-1) when 0 counting outwards from GrovePi, 0 = led closest to the GrovePi, otherwise counting inwards +def chainableRgbLed_setLevel(pin, level, reverse): + write_i2c_block(chainableRgbLedSetLevel_cmd + [pin, level, reverse]) + read_i2c_block(no_bytes = 1) + return 1 + +def set_pin_interrupt(pin, ftype, interrupt_mode, period): + ''' + Attach an interrupt to a pin. + + pin - D2-D8 pins + ftype - 0 for COUNT_CHANGES, 1 for COUNT_LOW_DURATION + interrupt_mode - 1 for CHANGE, 2 for FALLING, 3 for RISING + period - as measured in ms (max 65535 ms) + ''' + period_high = period >> 8 + period_low = period & 0xff + combined_params = (pin & 0x0f) + ((ftype & 0x03) << 4) + ((interrupt_mode & 0x03) << 6) + write_i2c_block(isr_set_cmd + [combined_params, period_high, period_low]) + read_i2c_block(no_bytes = 1) + +def unset_pin_interrupt(pin): + ''' + Detach an interrupt from a pin. + + pin - D2-D8 pins + ''' + write_i2c_block(isr_unset_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + +def unset_all_interrupts(): + ''' + Detach all attached interrupts from all D2-D8 pins. + + pin - D2-D8 pins + ''' + write_i2c_block(isr_clear_cmd + 3 * [unused]) + read_i2c_block(no_bytes = 1) + +def is_interrupt_active(pin): + write_i2c_block(isr_active_cmd + [pin, unused, unused]) + data = read_identified_i2c_block(isr_active_cmd, no_bytes = 2) + value = data[1] >> pin + return value != 0 + +def get_active_interrupts(): + ''' + Get list of attached interrupts for a given pin or all of them. + + pin - D2-D8 pins; if it's 255 return the state of all pins + ''' + pin = 255 + write_i2c_block(isr_active_cmd + [pin, unused, unused]) + data = read_identified_i2c_block(isr_active_cmd, no_bytes = 2) + value = data[0] + (data[1] << 8) + active_interrupts = [i for i in range(2 * 8) if ((value >> i) & 0x01)] + return active_interrupts + +def read_interrupt_state(pin): + ''' + Read number of pulses/changes on given port that occurred within a time period. + + pin - D2-D8 pins + ''' + write_i2c_block(isr_read_cmd + [pin, unused, unused]) + data = read_identified_i2c_block(isr_read_cmd, no_bytes = 4) + value = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24) + return value + +def dust_sensor_en(pin = 2, period = 30000): + set_pin_interrupt(pin, ftype=COUNT_LOW_DURATION, interrupt_mode=CHANGE, period=period) + +def dust_sensor_dis(pin = 2): + unset_pin_interrupt(pin) + +def dust_sensor_read(pin = 2, period = 30000): + ''' + By default, the sample rate is set to 1 at every 30 seconds and this + function was written only for that interval. + + If you wish to use a different + interval, then use dust_sensor_read_more function. To set a + different interval, use set_dust_sensor_interval function. + ''' + lpo = read_interrupt_state(pin) + percentage = 100.0 * lpo / period + concentration = 1.1 * percentage ** 3 - 3.8 * percentage ** 2 + 520 * percentage + 0.62 + + return lpo, percentage, concentration + +def encoder_en(pin = 2, steps = 32): + write_i2c_block(encoder_en_cmd + [pin, steps, unused]) + read_i2c_block(no_bytes = 1) + +def encoder_dis(pin = 2): + write_i2c_block(encoder_dis_cmd + [pin, unused, unused]) + read_i2c_block(no_bytes = 1) + +def encoderRead(pin = 2): + write_i2c_block(encoder_read_cmd + [pin, unused, unused]) + data = read_identified_i2c_block(encoder_read_cmd, no_bytes = 4) + value = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24) + return value + +def flowEnable(pin = 2, period = 2000): + set_pin_interrupt(pin, ftype=COUNT_CHANGES, interrupt_mode=RISING, period=period) + +def flowDisable(pin = 2): + unset_pin_interrupt(pin) + +def flowRead(pin = 2): + val = read_interrupt_state(pin) + return val + +def main(): + print("library supports this fw versions: " + + " ".join('{}'.format(k[1]) for k in enumerate(works_with_firmware))) + +if __name__ == "__main__": + main() diff --git a/orchestrateur/sensors/lock.py b/orchestrateur/sensors/lock.py new file mode 100644 index 0000000..e8b5f34 --- /dev/null +++ b/orchestrateur/sensors/lock.py @@ -0,0 +1,5 @@ +import threading +# Dedicated lock for I2C bus access (used by GrovePi sensors) +grove_lock = threading.Lock() +# Dedicated lock for UART/Serial port access +serial_lock = threading.Lock() \ No newline at end of file diff --git a/orchestrateur/sensors/temp_hum.py b/orchestrateur/sensors/temp_hum.py new file mode 100644 index 0000000..b01adf5 --- /dev/null +++ b/orchestrateur/sensors/temp_hum.py @@ -0,0 +1,52 @@ +# from sensors.lib import grove_i2c_temp_hum_mini + +# t= grove_i2c_temp_hum_mini.th02() + +# def get_temperature(): +# """Get the temperature in Celsius from the TH02 sensor.""" +# # try: +# return t.getTemperature() +# # except Exception as e: +# # print(f"Error reading temperature: {e}") +# # return None + +# def get_humidity(): +# """Get the humidity in percentage from the TH02 sensor.""" +# # try: +# return t.getHumidity() +# # except Exception as e: +# # print(f"Error reading humidity: {e}") +# # return None + +# import seeed_dht + +# sensor = seeed_dht.DHT("11", 4) # DHT11 sensor on GPIO pin 4 + +# def get_humidity_and_temperature(): +# humi, temp = sensor.read() +# return humi, temp + + +# import sensors.lib.grovepi as grovepi +import grovepi +import math +from sensors.lock import grove_lock + +# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3 +# This example uses the blue colored sensor. +# SIG,NC,VCC,GND +sensor = 3 # The Sensor goes on digital port 3. + +# temp_humidity_sensor_type +# Grove Base Kit comes with the blue sensor. +blue = 0 # The Blue colored sensor. +white = 1 # The White colored sensor. + +def get_temperature_and_humidity(): + with grove_lock: + [temp,humidity] = grovepi.dht(sensor,blue) + if math.isnan(temp) == False and math.isnan(humidity) == False: + return temp, humidity + else: + print("Error reading from DHT sensor") + return None, None diff --git a/orchestrateur/sensors/ultrasonicRanger.py b/orchestrateur/sensors/ultrasonicRanger.py new file mode 100644 index 0000000..b42c99e --- /dev/null +++ b/orchestrateur/sensors/ultrasonicRanger.py @@ -0,0 +1,32 @@ +import grovepi +from sensors.lock import grove_lock + +# Connect the Grove Ultrasonic Ranger to digital port D4 +# SIG,NC,VCC,GND +ULTRASONIC_RANGER_PORT = 4 + +def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT): + if not grove_lock.acquire(timeout=1.0): + print("Ultrasonic: Lock acquisition timed out") + return None + + try: + return grovepi.ultrasonicRead(ultrasonic_ranger) + except Exception as e: + print(f"Error: {e}") + return None + finally: + grove_lock.release() + +def get_dish_height(): + """Returns the height of the dish in centimeters.""" + distance = read_ultrasonic_ranger() + if distance is not None: + # Assuming the ultrasonic sensor is mounted at a fixed height above the dish + # and pointing downwards, we can calculate the height of the dish. + # For example, if the sensor is 30 cm above the dish when it's empty: + SENSOR_HEIGHT = 30 # cm + dish_height = SENSOR_HEIGHT - distance + return max(dish_height, 0) # Ensure height is not negative + else: + return None \ No newline at end of file diff --git a/shared/__init__.py b/shared/__init__.py index d782b38..69299d6 100644 --- a/shared/__init__.py +++ b/shared/__init__.py @@ -3,6 +3,7 @@ import shared.deviceTypes as deviceTypes import shared.config as config +import shared.payloads as payloads def get_lora(*args, **kwargs): from .lora_device import get_lora_device diff --git a/shared/config.py b/shared/config.py index 1752375..502adb9 100644 --- a/shared/config.py +++ b/shared/config.py @@ -1,12 +1,15 @@ - +DEBUG=True # LoRa -HEARTBEAT_INTERVAL = 10 +HEARTBEAT_INTERVAL = 30 # MQTT MQTT_BROKER_HOST = "192.168.50.1" +MQTT_TOPIC_HELLO = b"smartwave/hello" MQTT_TOPIC_SENSOR = b"smartwave/sensor" MQTT_TOPIC_COOKING = b"smartwave/cooking" MQTT_KEEPALIVE = 30 USE_TLS = True -MQTT_QOS = 1 \ No newline at end of file +MQTT_QOS = 1 +# Long because messages are stored into the broker and will be sent when the orchestrator is back online. +MQTT_HELLO_INTERVAL = 30 \ No newline at end of file diff --git a/shared/logging.py b/shared/logging.py new file mode 100644 index 0000000..f9d1d74 --- /dev/null +++ b/shared/logging.py @@ -0,0 +1,6 @@ +from shared.config import DEBUG + +def log(message): + """Log a message to the console if DEBUG is enabled.""" + if DEBUG: + print(f"\n{message}") \ No newline at end of file diff --git a/shared/mqtt.py b/shared/mqtt.py index 7de4a9e..53ce32c 100644 --- a/shared/mqtt.py +++ b/shared/mqtt.py @@ -13,13 +13,13 @@ except ImportError: from umqtt.simple import MQTTClient as _MQTTClient BACKEND_NAME = "umqtt.simple" IS_MICROPYTHON = True - except ImportError: - try: - from umqtt.robust import MQTTClient as _MQTTClient - BACKEND_NAME = "umqtt.robust" - IS_MICROPYTHON = True - except ImportError as exc: - raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc + # except ImportError: + # try: + # from umqtt.robust import MQTTClient as _MQTTClient + # BACKEND_NAME = "umqtt.robust" + # IS_MICROPYTHON = True + except ImportError as exc: + raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc DEFAULT_PORT = 8884 @@ -179,6 +179,38 @@ class BrokerClient: topic = topic.decode('utf-8') return client.subscribe(topic, qos=qos) + + def unsubscribe(self, topic): + client = self.open() + if IS_MICROPYTHON: + import struct + # Ensure the topic is bytes for writing to the socket + topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8') + + # 1. Build the MQTT unsubscribe packet header + pkt = bytearray(b"\xa2\0\0\0") + client.pid += 1 + + # Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length + struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid) + + # 2. Write the packet to the socket + client.sock.write(pkt) + client._send_str(topic_bytes) + + # 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker + while True: + op = client.wait_msg() + if op == 0xB0: + resp = client.sock.read(3) + assert resp[1] == pkt[2] and resp[2] == pkt[3] + return client + return client + + if isinstance(topic, bytes): + topic = topic.decode('utf-8') + + return client.unsubscribe(topic) def _on_micropython_message(self, topic, payload): self._store_message(topic, payload, None, False) diff --git a/shared/payloads.py b/shared/payloads.py index e69de29..19b350c 100644 --- a/shared/payloads.py +++ b/shared/payloads.py @@ -0,0 +1,23 @@ +try: + import ujson as json +except ImportError: + import json + +def as_json(data): + """Convert a dictionary to a JSON string.""" + try: + return json.dumps(data) + except Exception as e: + print("[Payloads] Error converting to JSON:", e) + return "{}" # Return an empty JSON object on error + +def mqtt_hello(id_microwave): + return as_json({ + "id_microwave": id_microwave + }) + +def mqtt_hello_ack(id_orchestrator, id_microwave): + return as_json({ + "id_microwave": id_microwave, + "id_orchestrator": id_orchestrator + }) \ No newline at end of file