diff --git a/.github/workflows/build-legacy-nginx.yml b/.github/workflows/build-legacy-nginx.yml index 27e5f7b..6b6ab6c 100644 --- a/.github/workflows/build-legacy-nginx.yml +++ b/.github/workflows/build-legacy-nginx.yml @@ -1,9 +1,8 @@ name: Build and Push Legacy Nginx +# Switch to a manual step as this does not change often. on: workflow_dispatch: - release: - types: [ published ] jobs: build-and-push: diff --git a/.home-assistant/sensors.yaml b/.home-assistant/sensors.yaml index 13e6e90..5c126f2 100644 --- a/.home-assistant/sensors.yaml +++ b/.home-assistant/sensors.yaml @@ -17,7 +17,7 @@ WHEN labels.label LIKE '%h1%' OR labels.label LIKE '%h2%' THEN (0.6 * 230 * readings.value) / 1000.0 WHEN labels.label LIKE '%h3%' - THEN (readings.value / 10.0) + THEN 1.0 * 230 * ((readings.value / 10.0) / 200) ELSE readings.value END AS W FROM readings diff --git a/README.md b/README.md index 2f2027e..6bf89d3 100644 --- a/README.md +++ b/README.md @@ -36,20 +36,61 @@ chmod +x ./generate-certs.sh This will create `server.key` and `server.crt` inside the `legacy-nginx` directory, where the `docker-compose.yml` file expects to find them. -### 2. Run the Services +### 2. Configuration + +The `hub-server` can be configured using environment variables in the `docker-compose.yml` file. + +#### Identifying your Hub Version + +Efergy Hubs (H1, H2, and H3) send data in different formats and to different endpoints. To ensure the `POWER_FACTOR` and data parsing are correct, you need to identify your hub version. + +The `hub-server` will automatically log the detected version at the `INFO` level when it receives the first packet from your device. + +1. **Check the logs**: View the logs of the `hub-server` container as the device sends data (usually every 6-30 seconds): + ```shell + docker logs -f hub-server + ``` +2. **Look for the detection message**: + - `Detected Efergy Hub version: H1` + - `Detected Efergy Hub version: H2` + - `Detected Efergy Hub version: H3` + +3. **Update Configuration**: Once identified, update your `docker-compose.yml` with the appropriate `POWER_FACTOR`: + - **H1 / H2**: `0.6` + - **H3**: `1.0` + +*Note: If you don't see the detection message, you can temporarily set `LOG_LEVEL: DEBUG` in `docker-compose.yml` to see all incoming requests and identify the endpoint (`/recjson` = H1, `/h2` = H2, `/h3` = H3).* + +#### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `TZ` | Timezone (e.g., `Australia/Brisbane`) | `Australia/Brisbane` | +| `LOG_LEVEL` | Logging verbosity (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | +| `MAINS_VOLTAGE` | Local mains voltage (e.g., `230` for AU/UK, `120` for US) | `230` | +| `POWER_FACTOR` | Power factor (H1/H2 use `0.6`, H3 uses `1.0`) | `0.6` | +| `HISTORY_RETENTION_MONTHS` | How many months of data to keep (`0` = keep everything) | `0` | +| `MQTT_ENABLED` | Enable or disable MQTT publishing (`true`/`false`) | `false` | +| `MQTT_BROKER` | IP address or hostname of your MQTT broker | `10.0.0.220` | +| `MQTT_PORT` | Port for your MQTT broker | `1883` | +| `MQTT_USER` | Username for MQTT broker | `None` | +| `MQTT_PASS` | Password for MQTT broker | `None` | +| `HA_DISCOVERY` | Enable Home Assistant MQTT Discovery (`true`/`false`) | `false` | +| `ENERGY_MONTHLY_RESET` | Reset cumulative energy in HA each month (`true`/`false`) | `false` | +| `DEVICE_URL` | Link to device management (e.g., Portainer or NAS UI) | `.../powermeter_hub_server` | + +### 3. Run the Services With the certificates in place, you can start both services using Docker Compose. ```shell -# Build and start the containers in detached mode -docker-compose up --build -d +# Start the containers in detached mode +docker-compose up -d ``` This will: -* Build the `hub-server` image from its Dockerfile. -* Build the `legacy-nginx` image from its Dockerfile. * Start both containers. The `legacy-nginx` service is exposed on port `443`. -* Automatically create the SQLite database on first run and mount the data directory for persistence. +* Automatically create the SQLite database on the first run and mount the data directory for persistence. ### 3. Redirect the Efergy Hub @@ -126,22 +167,14 @@ If your Efergy Hub server is running on HA OS, you can integrate the readings in 1. **Configure Environment Variables** for MQTT -Update your environment variables in the `docker-compose.yml` file: -``` -# Optional: logging level (DEBUG, INFO, WARN, ERROR) -LOG_LEVEL=INFO - -# Enable MQTT (true/false) -MQTT_ENABLED=true - -# MQTT broker details -MQTT_BROKER=homeassistant.local -MQTT_PORT=1883 -MQTT_USER=mqtt-broker-username-here -MQTT_PASS=your-password-here - -# Home Assistant MQTT Discovery -HA_DISCOVERY=true +Configure your environment variables in the `docker-compose.yml` file as described in the [Configuration](#configuration) section. +For Home Assistant with MQTT, you should at least set: +```yaml +MQTT_ENABLED: true +MQTT_BROKER: homeassistant.local +MQTT_USER: mqtt-broker-username-here +MQTT_PASS: your-password-here +HA_DISCOVERY: true ``` 2. **Home Assistant Auto-Discovery** diff --git a/docker-compose.yml b/docker-compose.yml index 2f3f91b..9102b8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,5 @@ services: hub-server: -# build: ./hub-server image: ghcr.io/devoldschool/powermeter_hub_server/hub-server:latest container_name: hub-server expose: @@ -11,9 +10,13 @@ services: TZ: Australia/Brisbane # Logging LOG_LEVEL: INFO + # Common voltages AU,UK = 230 | US = 120 https://en.wikipedia.org/wiki/Mains_electricity_by_country + MAINS_VOLTAGE: 230 + # H1 and H2 devices used a power factor of 0.6, H3 uses 1.0 + POWER_FACTOR: 0.6 # How many months of readings and aggregated values to keep, 0 = keep everything HISTORY_RETENTION_MONTHS: 0 - # MQTT + # MQTT https://www.home-assistant.io/integrations/mqtt/ MQTT_ENABLED: true MQTT_BROKER: homeassistant.local MQTT_PORT: 1883 @@ -21,11 +24,12 @@ services: MQTT_PASS: your-password-here # Home Assistant HA_DISCOVERY: true - # Setting to true will reset the cumulative energy value each month + # Setting to true will reset the cumulative energy value each month in Home Assistant ENERGY_MONTHLY_RESET: false + # Device URL, set this to your management page, example Portainer or your NAS + DEVICE_URL: "https://github.com/DevOldSchool/powermeter_hub_server" legacy-nginx: -# build: ./legacy-nginx image: ghcr.io/devoldschool/powermeter_hub_server/legacy-nginx:latest container_name: legacy-nginx ports: diff --git a/hub-server/config.py b/hub-server/config.py index 9e81ec6..e18b78a 100644 --- a/hub-server/config.py +++ b/hub-server/config.py @@ -3,6 +3,7 @@ # Hub server config SERVER_PORT = int(os.getenv("SERVER_PORT", "5000")) MAINS_VOLTAGE = int(os.getenv("MAINS_VOLTAGE", "230")) +# H1 and H2 devices use a power factor of 0.6, H3 uses 1.0 POWER_FACTOR = float(os.getenv("POWER_FACTOR", "0.6")) # Logging level, values are DEBUG, INFO, WARN, ERROR, CRITICAL @@ -34,6 +35,10 @@ # Formulas # AC single phase milliamps to watts calculation P(W) = (PF × V(V) × I(mA)) / 1000 +# {{ (__POWER_FACTOR__ * __MAINS_VOLTAGE__ * (value_json.value | float)) / 1000 }} + +# AC three phase milliamps to watts calculation P(W) = (√3 x PF × VL-L(V) × I(mA)) / 1000 +# {{ (1.732 * __POWER_FACTOR__ * __MAINS_VOLTAGE__ * (value_json.value | float)) / 1000 }} POWER_VALUE_TEMPLATE_H1_RAW = os.getenv("POWER_VALUE_TEMPLATE_H1_RAW", "{{ (__POWER_FACTOR__ * __MAINS_VOLTAGE__ * (value_json.value | float)) / 1000 }}") POWER_VALUE_TEMPLATE_H1 = POWER_VALUE_TEMPLATE_H1_RAW.replace("__MAINS_VOLTAGE__", str(MAINS_VOLTAGE)).replace("__POWER_FACTOR__", str(POWER_FACTOR)) @@ -43,9 +48,11 @@ POWER_VALUE_TEMPLATE_H2 = POWER_VALUE_TEMPLATE_H2_RAW.replace("__MAINS_VOLTAGE__", str(MAINS_VOLTAGE)).replace("__POWER_FACTOR__", str(POWER_FACTOR)) POWER_UNIT_OF_MEASUREMENT_H2 = os.getenv("POWER_UNIT_OF_MEASUREMENT_H2", "W") -POWER_VALUE_TEMPLATE_H3 = os.getenv("POWER_VALUE_TEMPLATE_H3", "{{ (value_json.value | float) / 10 }}") +POWER_VALUE_TEMPLATE_H3_RAW = os.getenv("POWER_VALUE_TEMPLATE_H3_RAW", "{{ __POWER_FACTOR__ * __MAINS_VOLTAGE__ * (((value_json.value | float) / 10) / 200) }}") +POWER_VALUE_TEMPLATE_H3 = POWER_VALUE_TEMPLATE_H3_RAW.replace("__MAINS_VOLTAGE__", str(MAINS_VOLTAGE)).replace("__POWER_FACTOR__", str(POWER_FACTOR)) POWER_UNIT_OF_MEASUREMENT_H3 = os.getenv("POWER_UNIT_OF_MEASUREMENT_H3", "W") +ENERGY_SENSOR_LABEL = "energy_consumption" ENERGY_NAME = os.getenv("ENERGY_NAME", "Energy consumption") ENERGY_ICON = os.getenv("ENERGY_ICON", "mdi:lightning-bolt") ENERGY_DEVICE_CLASS = os.getenv("ENERGY_DEVICE_CLASS", "energy") diff --git a/hub-server/database.py b/hub-server/database.py index d76b4b1..f178f6a 100755 --- a/hub-server/database.py +++ b/hub-server/database.py @@ -109,7 +109,8 @@ def setup(self) -> None: cursor.execute(""" CREATE TABLE IF NOT EXISTS labels ( label_id INTEGER PRIMARY KEY AUTOINCREMENT, - label STRING UNIQUE + label STRING UNIQUE, + firmware_version STRING ) """) cursor.execute(""" @@ -147,10 +148,17 @@ def setup(self) -> None: ON energy_hourly (hour_start) """) + # Add a firmware_version column if it doesn't exist + cursor.execute("PRAGMA table_info(labels)") + columns = [row[1] for row in cursor.fetchall()] + if "firmware_version" not in columns: + logging.info("Adding 'firmware_version' column to labels table") + cursor.execute("ALTER TABLE labels ADD COLUMN firmware_version STRING") + logging.debug("Database setup complete.") - def _get_or_create_label_id(self, cursor: sqlite3.Cursor, label: str) -> int: + def _get_or_create_label_id(self, cursor: sqlite3.Cursor, label: str, firmware_version: str) -> int: """ Gets a label_id from the cache or database. If the label doesn't exist, it's created. @@ -171,14 +179,23 @@ def _get_or_create_label_id(self, cursor: sqlite3.Cursor, label: str) -> int: return self._label_cache[label] # If not in cache, check database - cursor.execute("SELECT label_id FROM labels WHERE label=?", (label,)) + cursor.execute("SELECT label_id, firmware_version FROM labels WHERE label=?", (label,)) row = cursor.fetchone() if row: - label_id = row[0] + label_id, existing_firmware = row + # Update firmware only if it's provided and different + if firmware_version is not None and firmware_version != existing_firmware: + cursor.execute( + "UPDATE labels SET firmware_version=? WHERE label_id=?", + (firmware_version, label_id) + ) else: # Not in DB, so create it - cursor.execute("INSERT INTO labels(label) VALUES (?)", (label,)) + cursor.execute( + "INSERT INTO labels(label, firmware_version) VALUES (?, ?)", + (label, firmware_version) + ) label_id = cursor.lastrowid logging.debug(f"Created new label '{label}' with id {label_id}") @@ -186,7 +203,7 @@ def _get_or_create_label_id(self, cursor: sqlite3.Cursor, label: str) -> int: return label_id - def log_data(self, label: str, value: float, timestamp: Optional[int] = None) -> None: + def log_data(self, label: str, value: float, firmware_version: str, timestamp: Optional[int] = None) -> None: """ Logs a new data point to the database. @@ -196,6 +213,7 @@ def log_data(self, label: str, value: float, timestamp: Optional[int] = None) -> Args: label: The string identifier for the data (e.g., 'efergy_h2_123456'). value: The floating-point value of the reading. + firmware_version: The firmware version of the hub. timestamp: The Unix timestamp. If None, current time is used. """ if timestamp is None: @@ -204,7 +222,7 @@ def log_data(self, label: str, value: float, timestamp: Optional[int] = None) -> try: with self._get_connection() as conn: cursor = conn.cursor() - label_id = self._get_or_create_label_id(cursor, label) + label_id = self._get_or_create_label_id(cursor, label, firmware_version) # Insert the actual reading cursor.execute( @@ -225,8 +243,11 @@ def get_all_labels(self): try: with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT label FROM labels ORDER BY label ASC") - return [row[0] for row in cursor.fetchall()] + cursor.execute("SELECT label, firmware_version FROM labels ORDER BY label ASC") + return [ + {"label": row[0], "firmware_version": row[1]} + for row in cursor.fetchall() + ] except Exception as e: logging.error(f"Failed to fetch labels: {e}") return [] diff --git a/hub-server/hub_server.py b/hub-server/hub_server.py index c415864..6491d44 100755 --- a/hub-server/hub_server.py +++ b/hub-server/hub_server.py @@ -41,6 +41,7 @@ def __init__(self, self.database = database self.mqtt_manager = mqtt_manager self.published_discovery = set() + self.detected_versions = set() super().__init__(server_address, request_handler_class, bind_and_activate) @@ -178,6 +179,8 @@ def do_POST(self): post_data_bytes = self.rfile.read(content_length) logging.debug(f">>> POST body: {post_data_bytes.decode('utf-8', 'ignore')}") + firmware_version = self.headers.get("X-Version", "") + db = getattr(self.server, "database", None) if not db: logging.error("Database not initialized on server instance.") @@ -190,7 +193,7 @@ def do_POST(self): logging.debug(f"Received ping from sensors: {sensor_ids}") elif parsed_url.path in ["/h2", "/h3"]: hub_version = parsed_url.path.strip("/") - self.process_sensor_data(post_data_bytes, hub_version, db) + self.process_sensor_data(post_data_bytes, hub_version, firmware_version, db) elif parsed_url.path == '/recjson': # v1 hub sends URL-encoded form data: json= hub_version = 'h1' @@ -198,7 +201,7 @@ def do_POST(self): if decoded_body.startswith('json='): # Extract the actual sensor data sensor_data = decoded_body[5:] # Skip 'json=' - self.process_sensor_data(sensor_data.encode('utf-8'), hub_version, db) + self.process_sensor_data(sensor_data.encode('utf-8'), hub_version, "", db) else: logging.warning(f"Unexpected /recjson body format: {decoded_body[:100]}") else: @@ -247,9 +250,13 @@ def do_CONNECT(self): self._send_response(200, b"success") - def process_sensor_data(self, post_data_bytes: bytes, hub_version: str, database: Database): + def process_sensor_data(self, post_data_bytes: bytes, hub_version: str, firmware_version: str, database: Database): """Parses and logs sensor data from the POST body.""" - parsed_results = parse_sensor_payload(post_data_bytes, hub_version) + parsed_results = parse_sensor_payload(post_data_bytes, hub_version, firmware_version) + + if hub_version.upper() not in self.server.detected_versions: + logging.info(f"Detected Efergy Hub version: {hub_version.upper()}") + self.server.detected_versions.add(hub_version.upper()) for data in parsed_results: try: @@ -264,12 +271,13 @@ def process_sensor_data(self, post_data_bytes: bytes, hub_version: str, database sid = data["sid"] label = data["label"] value = data["value"] + firmware_version = data.get("firmware_version", firmware_version) logging.debug(f"Logging sensor: {label}, raw: {value}") - database.log_data(label, value) + database.log_data(label, value, firmware_version) # Publish power reading - self.server.mqtt_manager.publish_power(label, sid, hub_version, value) + self.server.mqtt_manager.publish_power(label, sid, hub_version, firmware_version, value) except Exception as e: logging.error(f"Unexpected error processing parsed data {data}: {e}") diff --git a/hub-server/mqtt_manager.py b/hub-server/mqtt_manager.py index 0099951..bf10728 100644 --- a/hub-server/mqtt_manager.py +++ b/hub-server/mqtt_manager.py @@ -2,7 +2,6 @@ import logging import time import paho.mqtt.client as mqtt -from __version__ import __version__ from config import ( MQTT_ENABLED, MQTT_BROKER, MQTT_PORT, MQTT_USER, MQTT_PASS, MQTT_BASE_TOPIC, HA_DISCOVERY, HA_DISCOVERY_PREFIX, @@ -10,13 +9,11 @@ POWER_UNIT_OF_MEASUREMENT_H1, POWER_VALUE_TEMPLATE_H1, POWER_UNIT_OF_MEASUREMENT_H2, POWER_VALUE_TEMPLATE_H2, POWER_UNIT_OF_MEASUREMENT_H3, POWER_VALUE_TEMPLATE_H3, - ENERGY_NAME, ENERGY_ICON, ENERGY_DEVICE_CLASS, ENERGY_STATE_CLASS, + ENERGY_NAME, ENERGY_ICON, ENERGY_DEVICE_CLASS, ENERGY_STATE_CLASS, ENERGY_SENSOR_LABEL, ENERGY_UNIT_OF_MEASUREMENT, ENERGY_VALUE_TEMPLATE, - DEVICE_NAME, DEVICE_MODEL, DEVICE_IDENTIFIERS, DEVICE_MANUFACTURER, - DEVICE_URL + DEVICE_NAME, DEVICE_MODEL, DEVICE_IDENTIFIERS, DEVICE_MANUFACTURER, DEVICE_URL ) -ENERGY_SENSOR_LABEL = "energy_consumption" def get_topic(label, sensor_type="power"): if sensor_type == "power": @@ -25,7 +22,6 @@ def get_topic(label, sensor_type="power"): return f"{MQTT_BASE_TOPIC}/{label}/energy" - class MQTTManager: def __init__(self, max_retries: int = 10, retry_interval: int = 5): self.enabled = MQTT_ENABLED @@ -34,7 +30,6 @@ def __init__(self, max_retries: int = 10, retry_interval: int = 5): self.max_retries = max_retries self.retry_interval = retry_interval self.connected = False - self.hub_version: str | None = None if not self.enabled: logging.debug("MQTT disabled via config.") @@ -90,7 +85,7 @@ def _on_disconnect(self, client, userdata, flags, reason_code, properties): # Generic publishing - def publish(self, topic: str, payload: dict, retain: bool = False): + def publish(self, topic: str, payload: dict | str, retain: bool = False): if not self.enabled: return @@ -106,19 +101,23 @@ def publish(self, topic: str, payload: dict, retain: bool = False): return try: - json_payload = json.dumps(payload) - self.client.publish(topic, json_payload, retain=retain) - logging.debug(f"MQTT published to {topic}: {json_payload[:400]}") + if isinstance(payload, (dict, list)): + final_payload = json.dumps(payload) + else: + final_payload = payload + + info = self.client.publish(topic, final_payload, retain=retain) + info.wait_for_publish() + + message = str(final_payload)[:600] if final_payload is not None else "" + logging.debug(f"MQTT published to {topic}: {message}") except Exception as e: logging.error(f"MQTT publish failed: {topic} — {e}") - def publish_power_discovery(self, label: str, sid: str, topic: str, hub_version: str): + def publish_power_discovery(self, label: str, sid: str, topic: str, hub_version: str, firmware_version: str): if not self.enabled or not HA_DISCOVERY: return - if self.hub_version is None: - logging.warning("Energy discovery attempted without hub_version – skipped") - return config_topic = f"{HA_DISCOVERY_PREFIX}/sensor/{label}/config" @@ -152,11 +151,15 @@ def publish_power_discovery(self, label: str, sid: str, topic: str, hub_version: "identifiers": DEVICE_IDENTIFIERS, "manufacturer": DEVICE_MANUFACTURER, "model": DEVICE_MODEL, + "sw_version": firmware_version, "hw_version": f"{hub_version}", - "configuration_url": DEVICE_URL + "configuration_url": DEVICE_URL, } } + # Clear existing discovery first + self.publish(config_topic, "", retain=True) + # Publish new discovery self.publish(config_topic, payload, retain=True) self.discovery_sent.add(label) @@ -184,24 +187,22 @@ def publish_energy_discovery(self, topic: str): "identifiers": DEVICE_IDENTIFIERS, "manufacturer": DEVICE_MANUFACTURER, "model": DEVICE_MODEL, - "configuration_url": DEVICE_URL + "configuration_url": DEVICE_URL, } } + # Clear existing discovery first + self.publish(config_topic, "", retain=True) + # Publish new discovery self.publish(config_topic, payload, retain=True) self.discovery_sent.add(ENERGY_SENSOR_LABEL) # Publishes reading AND automatically discovery if needed - def publish_power(self, label: str, sid: str, hub_version: str, value: float): + def publish_power(self, label: str, sid: str, hub_version: str, firmware_version: str, value: float): if not self.enabled: return - # Store hub version once - if self.hub_version is None: - self.hub_version = hub_version - logging.info(f"Detected hub version: {hub_version}") - logging.debug(f"Publishing power for {label} with value {value}") topic = get_topic(label, sensor_type="power") @@ -210,7 +211,7 @@ def publish_power(self, label: str, sid: str, hub_version: str, value: float): # Publish discovery ONLY once if self.discovery_enabled and label not in self.discovery_sent: - self.publish_power_discovery(label, sid, topic, hub_version) + self.publish_power_discovery(label, sid, topic, hub_version, firmware_version) self.discovery_sent.add(label) @@ -241,7 +242,13 @@ def publish_startup_discovery(self, labels): logging.debug(f"Publishing discovery info for {len(labels)} stored sensors...") - for label in labels: + for label_info in labels: + label = label_info.get("label") + firmware_version = label_info.get("firmware_version", "") + + if not label: + continue + parts = label.split("_") if len(parts) < 3: continue @@ -256,7 +263,7 @@ def publish_startup_discovery(self, labels): sid = parts[2] power_topic = get_topic(label, sensor_type="power") - self.publish_power_discovery(label, sid, power_topic, hub_version) + self.publish_power_discovery(label, sid, power_topic, hub_version, firmware_version) # Energy topic energy_topic = get_topic(ENERGY_SENSOR_LABEL, sensor_type="energy") diff --git a/hub-server/payload_parser.py b/hub-server/payload_parser.py index 158a21f..c9e42a2 100644 --- a/hub-server/payload_parser.py +++ b/hub-server/payload_parser.py @@ -2,7 +2,7 @@ import logging from typing import List, Optional -def parse_sensor_line(line: str, hub_version: str) -> Optional[dict]: +def parse_sensor_line(line: str, hub_version: str, firmware_version: str) -> Optional[dict]: """ Parses a single line of sensor data. @@ -26,6 +26,9 @@ def parse_sensor_line(line: str, hub_version: str) -> Optional[dict]: rssi_val = None # --- EFMS1 Multi-sensor processing --- if data_type.startswith("EFMS"): + # M = motion + # T = temperature + # L = light raw_block = data[3] # Example: "M,64.00&T,0.00&L,0.00" # Some packets include RSSI after an additional pipe @@ -64,6 +67,7 @@ def parse_sensor_line(line: str, hub_version: str) -> Optional[dict]: # MAC address = sensor ID for V1 sid = data[0] + firmware_version = data[1] jdata = json.loads(data[3]) value = float(jdata['data'][0][3]) label = f"efergy_{hub_version}_{sid}" @@ -89,8 +93,9 @@ def parse_sensor_line(line: str, hub_version: str) -> Optional[dict]: "sid": sid, "label": label, "value": value, + "rssi": rssi_val, "hub_version": hub_version, - "rssi": rssi_val + "firmware_version": firmware_version, } except (IndexError, ValueError, TypeError, json.JSONDecodeError) as e: @@ -101,7 +106,7 @@ def parse_sensor_line(line: str, hub_version: str) -> Optional[dict]: return None -def parse_sensor_payload(post_data_bytes: bytes, hub_version: str) -> List[dict]: +def parse_sensor_payload(post_data_bytes: bytes, hub_version: str, firmware_version: str) -> List[dict]: """ Parses a full POST body containing sensor data. """ @@ -114,7 +119,7 @@ def parse_sensor_payload(post_data_bytes: bytes, hub_version: str) -> List[dict] results = [] for line in sensor_lines: - parsed = parse_sensor_line(line, hub_version) + parsed = parse_sensor_line(line, hub_version, firmware_version) if parsed: results.append(parsed) diff --git a/hub-server/tests/test_database.py b/hub-server/tests/test_database.py index e17a7e8..f04a23a 100644 --- a/hub-server/tests/test_database.py +++ b/hub-server/tests/test_database.py @@ -30,11 +30,12 @@ def test_database_setup(db_path): def test_log_data_and_labels(db): - db.log_data("test_label", 100.0, timestamp=1000) - db.log_data("test_label", 200.0, timestamp=1100) - db.log_data("another_label", 50.0, timestamp=1200) + db.log_data("test_label", 100.0, "2.3.7", timestamp=1000) + db.log_data("test_label", 200.0, "2.3.7", timestamp=1100) + db.log_data("another_label", 50.0, "2.3.7", timestamp=1200) - labels = db.get_all_labels() + labels_data = db.get_all_labels() + labels = [item["label"] for item in labels_data] assert "test_label" in labels assert "another_label" in labels assert len(labels) == 2 @@ -52,9 +53,9 @@ def test_aggregate_one_hour(db): # Add some readings in this hour # efergy_h1 uses (PF * V * I/1000) / 1000 for kW - db.log_data("efergy_h1_test", 1000.0, timestamp=hour_start) # 1000mA -> (0.6 * 230 * 1) / 1000 = 0.138 kW - db.log_data("efergy_h1_test", 2000.0, timestamp=hour_start + 1800) # 2000mA -> 0.276 kW - db.log_data("efergy_h1_test", 1000.0, timestamp=hour_start + 3600) # Next hour, shouldn't be included in this aggregation + db.log_data("efergy_h1_test", 1000.0, "2.3.7", timestamp=hour_start) # 1000mA -> (0.6 * 230 * 1) / 1000 = 0.138 kW + db.log_data("efergy_h1_test", 2000.0, "2.3.7", timestamp=hour_start + 1800) # 2000mA -> 0.276 kW + db.log_data("efergy_h1_test", 1000.0, "2.3.7", timestamp=hour_start + 3600) # Next hour, shouldn't be included in this aggregation with sqlite3.connect(db.db_path) as conn: cursor = conn.cursor() @@ -78,8 +79,8 @@ def test_aggregate_hours(db): hour1_start = (now - 7200) - (now % 3600) hour2_start = hour1_start + 3600 - db.log_data("efergy_h3_test", 100.0, timestamp=hour1_start) # h3 uses value/10/1000 = 0.01 kW - db.log_data("efergy_h3_test", 100.0, timestamp=hour1_start + 3599) + db.log_data("efergy_h3_test", 100.0, "2.3.7", timestamp=hour1_start) # h3 uses value/10/1000 = 0.01 kW + db.log_data("efergy_h3_test", 100.0, "2.3.7", timestamp=hour1_start + 3599) processed = db.aggregate_hours() assert processed >= 1 @@ -93,14 +94,14 @@ def test_truncate_old_data(db): now = int(time.time()) two_months_ago = now - (62 * 24 * 3600) - db.log_data("old_label", 100.0, timestamp=two_months_ago) - db.log_data("new_label", 100.0, timestamp=now) + db.log_data("old_label", 100.0, "2.3.7", timestamp=two_months_ago) + db.log_data("new_label", 100.0, "2.3.7", timestamp=now) # Aggregate to have something in energy_hourly to # Need to make sure we have a full hour for the old data old_hour_start = two_months_ago - (two_months_ago % 3600) - db.log_data("old_label", 100.0, timestamp=old_hour_start) - db.log_data("old_label", 100.0, timestamp=old_hour_start + 3599) + db.log_data("old_label", 100.0, "2.3.7", timestamp=old_hour_start) + db.log_data("old_label", 100.0, "2.3.7", timestamp=old_hour_start + 3599) with sqlite3.connect(db.db_path) as conn: db.aggregate_one_hour(conn.cursor(), old_hour_start) @@ -120,7 +121,7 @@ def test_truncate_old_data(db): def test_reconnect(db): - db.log_data("reconnect_test", 123.0, timestamp=1000) + db.log_data("reconnect_test", 123.0, "2.3.7", timestamp=1000) # Simulate connection loss if db._conn: @@ -128,7 +129,7 @@ def test_reconnect(db): db._conn = None # Now log again — _get_connection should reconnect automatically - db.log_data("reconnect_test", 456.0, timestamp=1100) + db.log_data("reconnect_test", 456.0, "2.3.7", timestamp=1100) # Verify both entries exist with sqlite3.connect(db.db_path) as conn: @@ -162,7 +163,7 @@ def failing_connect(): db._connect = failing_connect # Attempt to log data — first attempt will fail, second should succeed - db.log_data("error_test_label", 42.0) + db.log_data("error_test_label", 42.0, "2.3.7") # Check that a warning was logged warnings = [rec for rec in caplog.records if "DB connection error" in rec.message] diff --git a/hub-server/tests/test_payload_parser.py b/hub-server/tests/test_payload_parser.py index 1e3ce3b..1f53df5 100644 --- a/hub-server/tests/test_payload_parser.py +++ b/hub-server/tests/test_payload_parser.py @@ -4,8 +4,9 @@ def test_h1_payload(): line = 'MAC123|694851F9|v1.0.1|{"data":[[610965,"mA","E1",33314,0,0,65535]]}|39ef0bdc14b52df375b79555f059b52f' hub_version = "h1" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is not None assert result["type"] == "CT" @@ -17,8 +18,9 @@ def test_h1_payload(): def test_h2_single_sensor_payload(): line = "741459|1|EFCT|P1,2479.98" hub_version = "h2" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is not None assert result["type"] == "CT" @@ -31,8 +33,9 @@ def test_h2_single_sensor_payload(): def test_h2_multi_sensor_example(): line = "747952|0|EFMS1|M,96.00&T,0.00&L,0.00|-67" hub_version = "h2" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is not None assert result["type"] == "EFMS" @@ -44,8 +47,9 @@ def test_h2_multi_sensor_example(): def test_h3_example(): line = "815751|1|EFCT|P1,391.86|-66" hub_version = "h3" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is not None assert result["type"] == "CT" @@ -57,8 +61,9 @@ def test_h3_example(): def test_efms_payload(): line = "741459|1|EFMS|M,64.00&T,22.50&L,100.00|85" hub_version = "h2" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is not None assert result["type"] == "EFMS" @@ -73,7 +78,8 @@ def test_efms_payload(): def test_hub_status_line(): line = "0|1|STATUS|OK" hub_version = "h2" + firmware_version = "2.3.7" - result = parse_sensor_line(line, hub_version) + result = parse_sensor_line(line, hub_version, firmware_version) assert result is None