diff --git a/.gitignore b/.gitignore index 36b13f1..2b96b49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Wifi/MQTT configuration +config.py + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -173,4 +176,3 @@ cython_debug/ # PyPI configuration file .pypirc - diff --git a/config.example.py b/config.example.py new file mode 100644 index 0000000..dab44f9 --- /dev/null +++ b/config.example.py @@ -0,0 +1,14 @@ +# Wi-Fi credentials +WIFI_SSID = "YourSSID" # Set this to your Wi-Fi network name +WIFI_PASS = "YourPassword" # Set this to your Wi-Fi network password + +# MQTT broker details +MQTT_HOST = "192.168.1.50" # MQTT broker IP/hostname +MQTT_PORT = 1883 # MQTT broker port +MQTT_USER = "mqtt_user" # MQTT username (leave blank for no login) +MQTT_PASS = "mqtt_password" # MQTT password (leave blank for no login) + +# Home Assistant node details +NODE_NAME = "Room Weather" # Name that's displayed on HA Device Card +NODE_ID = "room_env" # Unique slug for entity_id and MQTT topic path +CLIENT_ID = f"pico2w_{NODE_ID}" # Unique client ID used by the MQTT broker diff --git a/main.py b/main.py new file mode 100644 index 0000000..a2df237 --- /dev/null +++ b/main.py @@ -0,0 +1,319 @@ +import time +import struct +import socket +import network +from machine import Pin, SoftI2C + +# ------------------------------------------------------------- +# Configuration Loader +# ------------------------------------------------------------- +try: + import config as Config +except ImportError: + print("ERROR: config.py not found.") + print("Create config.py on the device with your Wi-Fi and MQTT credentials.") + while True: + time.sleep(2) + +# Default NODE_NAME to NODE_ID if not explicitly provided in config.py +NODE_NAME = getattr(Config, "NODE_NAME", Config.NODE_ID) +STATE_TOPIC = f"homeassistant/sensor/{Config.NODE_ID}/state" + + +# ------------------------------------------------------------- +# Minimal MQTT 3.1.1 Client +# ------------------------------------------------------------- +class SimpleMQTT: + def __init__(self, host, port=1883): + self.host = host + self.port = port + self.sock = None + + def connect(self, client_id, username=None, password=None): + addr_info = socket.getaddrinfo(self.host, self.port) + addr = addr_info[0][-1] + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect(addr) + + # Flags: Clean session (bit 1) + optional credentials + flags = 0x02 + if username: + flags |= 0x80 + if password: + flags |= 0x40 + + # Variable header: Protocol Name (MQTT) + Level (4) + Flags + KeepAlive (60s) + var_header = bytearray([0x00, 0x04, ord('M'), ord('Q'), ord('T'), ord('T'), 0x04, flags, 0x00, 0x3C]) + + # Payload + payload = bytearray() + payload.extend(self._encode_str(client_id)) + if username: + payload.extend(self._encode_str(username)) + if password: + payload.extend(self._encode_str(password)) + + body = var_header + payload + packet = bytearray([0x10]) + self._encode_len(len(body)) + body + self.sock.write(packet) + + # Read CONNACK (4 bytes: 0x20, 0x02, ack_flags, return_code) + resp = self.sock.read(4) + if not resp or resp[0] != 0x20 or resp[3] != 0x00: + code = resp[3] if resp and len(resp) >= 4 else "nil" + raise RuntimeError(f"MQTT connection failed: code {code}") + + def publish(self, topic, payload, retain=False): + cmd = 0x30 | (0x01 if retain else 0x00) + if isinstance(payload, str): + payload = payload.encode("utf-8") + body = self._encode_str(topic) + payload + packet = bytearray([cmd]) + self._encode_len(len(body)) + body + self.sock.write(packet) + + def disconnect(self): + try: + if self.sock: + self.sock.write(bytearray([0xE0, 0x00])) + self.sock.close() + except Exception: + pass + finally: + self.sock = None + + def _encode_str(self, s): + raw = s.encode("utf-8") + return struct.pack("!H", len(raw)) + raw + + def _encode_len(self, length): + encoded = bytearray() + while True: + digit = length % 128 + length //= 128 + if length > 0: + digit |= 0x80 + encoded.append(digit) + if length == 0: + break + return encoded + + +# ------------------------------------------------------------- +# BME280 Driver (I2C) +# ------------------------------------------------------------- +class BME280: + DEFAULT_ADDR = 0x77 + + def __init__(self, i2c, addr=DEFAULT_ADDR): + self.i2c = i2c + self.addr = addr + + # Reset sensor + self.i2c.writeto_mem(self.addr, 0xE0, b"\xB6") + time.sleep(0.1) + + self._read_calibration_data() + + # Humidity oversampling x1 + self.i2c.writeto_mem(self.addr, 0xF2, b"\x01") + # Pressure oversampling x1, Temp oversampling x1, Normal mode + self.i2c.writeto_mem(self.addr, 0xF4, b"\x27") + # Standby 1000ms, filter off + self.i2c.writeto_mem(self.addr, 0xF5, b"\xA0") + + def _read_calibration_data(self): + calib = self.i2c.readfrom_mem(self.addr, 0x88, 24) + c = struct.unpack("> 4) | (e5_e6[1] << 4) + + self.dig_h6 = struct.unpack("> 4 + raw_t = ((raw[3] << 16) | (raw[4] << 8) | raw[5]) >> 4 + raw_h = (raw[6] << 8) | raw[7] + + # Temperature calculation (Celsius) + var1 = (((raw_t >> 3) - (self.dig_t1 << 1)) * self.dig_t2) >> 11 + var2 = (((((raw_t >> 4) - self.dig_t1) * ((raw_t >> 4) - self.dig_t1)) >> 12) * self.dig_t3) >> 14 + t_fine = var1 + var2 + temp = ((t_fine * 5 + 128) >> 8) / 100.0 + + # Pressure calculation (hPa) + p_var1 = t_fine - 128000 + p_var2 = p_var1 * p_var1 * self.dig_p6 + ((p_var1 * self.dig_p5) << 17) + (self.dig_p4 << 35) + p_var1 = ((p_var1 * p_var1 * self.dig_p3) >> 8) + ((p_var1 * self.dig_p2) << 12) + p_var1 = (((1 << 47) + p_var1) * self.dig_p1) >> 33 + + if p_var1 == 0: + pressure = 0.0 + else: + p = 1048576 - raw_p + p = (((p << 31) - p_var2) * 3125) // p_var1 + p_var1 = (self.dig_p9 * (p >> 13) * (p >> 13)) >> 25 + p_var2 = (self.dig_p8 * p) >> 19 + p = ((p + p_var1 + p_var2) >> 8) + (self.dig_p7 << 4) + pressure = (p / 256.0) / 100.0 + + # Humidity calculation (%) + h_var = t_fine - 76800 + h_var = ( + ((((raw_h << 14) - (self.dig_h4 << 20) - (self.dig_h5 * h_var)) + 16384) >> 15) + * ( + ( + ( + ((((h_var * self.dig_h6) >> 10) * (((h_var * self.dig_h3) >> 11) + 32768)) >> 10) + + 2097152 + ) + * self.dig_h2 + + 8192 + ) + >> 14 + ) + ) + h_var = h_var - (((((h_var >> 15) * (h_var >> 15)) >> 7) * self.dig_h1) >> 4) + h_var = max(0, min(h_var, 419430400)) + humidity = (h_var >> 12) / 1024.0 + + return { + "temperature": round(temp, 2), + "pressure": round(pressure, 2), + "humidity": round(humidity, 2) + } + + +# ------------------------------------------------------------- +# Home Assistant MQTT Discovery +# ------------------------------------------------------------- +def register_ha_discovery(mqtt): + # Device naming uses NODE_NAME so each board has its own clean device card in HA + device_info = ( + f'"device":{{' + f'"identifiers":["{Config.NODE_ID}"],' + f'"name":"{NODE_NAME}",' + f'"model":"Pico 2 W",' + f'"manufacturer":"Raspberry Pi"' + f'}}' + ) + + configs = [ + { + "id": "temperature", + "name": "Temperature", + "unit": "°C", + "cls": "temperature", + "val_tpl": "{{ value_json.temperature }}" + }, + { + "id": "humidity", + "name": "Humidity", + "unit": "%", + "cls": "humidity", + "val_tpl": "{{ value_json.humidity }}" + }, + { + "id": "pressure", + "name": "Pressure", + "unit": "hPa", + "cls": "atmospheric_pressure", + "val_tpl": "{{ value_json.pressure }}" + } + ] + + for c in configs: + topic = f"homeassistant/sensor/{Config.NODE_ID}/{c['id']}/config" + unique_id = f"{Config.NODE_ID}_{c['id']}" + payload = ( + f'{{"name":"{c["name"]}",' + f'"has_entity_name":true,' + f'"unique_id":"{unique_id}",' + f'"device_class":"{c["cls"]}",' + f'"state_class":"measurement",' + f'"unit_of_measurement":"{c["unit"]}",' + f'"state_topic":"{STATE_TOPIC}",' + f'"value_template":"{c["val_tpl"]}",' + f'{device_info}}}' + ) + mqtt.publish(topic, payload, retain=True) + print(f"Registered HA discovery: {c['id']}") + + +# ------------------------------------------------------------- +# Main Execution Loop +# ------------------------------------------------------------- +def connect_wifi(): + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + if not wlan.isconnected(): + print(f"Connecting to Wi-Fi '{Config.WIFI_SSID}'...") + wlan.connect(Config.WIFI_SSID, Config.WIFI_PASS) + timeout = 15 + while not wlan.isconnected() and timeout > 0: + time.sleep(1) + timeout -= 1 + + if not wlan.isconnected(): + raise RuntimeError("Failed to connect to Wi-Fi") + print(f"Wi-Fi connected. IP: {wlan.ifconfig()[0]}") + + +def main(): + # Adjust SDA/SCL pins according to your wiring (Default GP4/GP5 for SoftI2C) + i2c = SoftI2C(sda=Pin(16), scl=Pin(17), freq=100000) + bme = BME280(i2c) + + connect_wifi() + + mqtt_user = getattr(Config, "MQTT_USER", None) + mqtt_pass = getattr(Config, "MQTT_PASS", None) + mqtt_port = getattr(Config, "MQTT_PORT", 1883) + + mqtt = SimpleMQTT(Config.MQTT_HOST, mqtt_port) + print(f"Connecting to MQTT broker at {Config.MQTT_HOST}...") + mqtt.connect(Config.CLIENT_ID, mqtt_user, mqtt_pass) + print("MQTT connected.") + + # Publish discovery retained messages + register_ha_discovery(mqtt) + + # Telemetry loop + interval = getattr(Config, "UPDATE_INTERVAL", 30) + while True: + try: + data = bme.read_values() + state_json = ( + f'{{"temperature":{data["temperature"]},' + f'"humidity":{data["humidity"]},' + f'"pressure":{data["pressure"]}}}' + ) + mqtt.publish(STATE_TOPIC, state_json) + print(f"[{NODE_NAME}] Published: {state_json}") + except Exception as e: + print(f"Error publishing state: {e}") + try: + mqtt.disconnect() + time.sleep(2) + connect_wifi() + mqtt.connect(Config.CLIENT_ID, mqtt_user, mqtt_pass) + except Exception as reconnect_err: + print(f"Reconnect failed: {reconnect_err}") + + time.sleep(interval) + + +if __name__ == "__main__": + main()