diff --git a/LICENSE b/LICENSE index b900f58..29e215a 100644 --- a/LICENSE +++ b/LICENSE @@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. M1730-ESP32 - Copyright (C) 2026 adebaumann + Copyright (C) 2026 Adrian A. Baumann This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. @@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - M1730-ESP32 Copyright (C) 2026 adebaumann + M1730-ESP32 Copyright (C) 2026 Adrian A. Baumann This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..049e681 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,226 @@ +# M1730-ESP32 + +ESP32 firmware that drives one or more **M1730 analog panel meters** (moving-coil ammeters) via PWM, with a web configuration UI and Home Assistant integration over MQTT. + +## Table of contents + +- [How it works](#how-it-works) +- [Hardware](#hardware) +- [Building and flashing](#building-and-flashing) +- [First-time Wi-Fi setup](#first-time-wi-fi-setup) +- [Web UI](#web-ui) +- [MQTT / Home Assistant](#mqtt--home-assistant) +- [HTTP API](#http-api) +- [Configuration reference](#configuration-reference) + +--- + +## How it works + +Each meter needle is controlled by a PWM signal on a GPIO pin. The firmware maps a **0–100 %** value to a PWM duty cycle, scaled by a per-meter **Max Duty** percentage that you calibrate for full-scale deflection. All settings are stored in LittleFS (`/config.json`) so they survive reboots. + +``` +needle position = (currentValue / 100) × (maxDuty / 100) × 1023 ticks +``` + +PWM runs at 5 kHz with 10-bit resolution (0–1023). + +### Physical range mapping + +Each meter has configurable **Min** / **Max** values that define its physical range (e.g. 0–50 A). The firmware normalises to 0–100 % internally, but MQTT publishes and receives in physical units — Home Assistant never has to deal with percentages. + +--- + +## Hardware + +| Item | Details | +|------|---------| +| MCU | ESP32-S3 DevKitC-1 | +| Meter | M1730 moving-coil panel ammeter (or any PWM-driveable analog meter) | +| Max meters | 8 simultaneous (ESP32 LEDC channels 0–7) | +| PWM freq | 5 kHz | +| PWM resolution | 10-bit | + +Connect the meter coil (via a current-limiting resistor sized for full-scale) between a GPIO pin and GND. Find the correct resistor value by raising **Max Duty** slowly until the needle reaches full scale. 660 + +--- + +## Building and flashing + +The project uses [PlatformIO](https://platformio.org/). + +```bash +# Build +pio run + +# Flash +pio run --target upload + +# Open serial monitor (115200 baud) +pio device monitor +``` + +Board target: `esp32-s3-devkitc-1` + +--- + +## First-time Wi-Fi setup + +On first boot (or when stored Wi-Fi credentials are missing), the device starts an access point named **M1730**. Connect to it with any phone or laptop — a captive portal will appear automatically. + +1. Enter your Wi-Fi SSID and password. +2. Optionally change the **Device hostname** (default: `m1730`). +3. Click **Save**. The device connects to your network and restarts. + +After connecting, the device is reachable at: + +- `http://m1730.local` (mDNS, works on most local networks) +- `http://` (shown in the serial monitor on boot) + +--- + +## Web UI + +Browse to the device address to open the configuration page. + +### Info panel + +Shows the current hostname and IP address. + +### Hostname + +Sets the mDNS name (`.local`) and the MQTT device name. Saved across reboots. + +### Meters + +Use the **Meters** dropdown to add or remove meters (1–10). Each meter has: + +| Field | Description | +|-------|-------------| +| Pin | GPIO pin number connected to the meter coil via current limiting resistor | +| Name | Label shown in Home Assistant and the web UI | +| Unit | Optional unit string shown in Home Assistant (e.g. `W`, `A`, `°C`) | +| Min / Max | Physical range of the meter (e.g. 0–50 A, 0–3000 W). MQTT publishes and receives values in this range; HA discovery uses these as the number entity min/max | +| Max Duty | PWM duty at full scale, as a percentage (0–100). Calibrate this so the needle just reaches full deflection | +| Output slider | Moves the needle live (0–100 % of the configured range). Also sent to MQTT | + +Click **Save** to persist all settings. Changing the meter count also triggers an immediate save and meter re-attach. + +--- + +## MQTT / Home Assistant + +### Enabling MQTT + +In the **MQTT** section of the web UI: + +| Field | Description | +|-------|-------------| +| Enable | Toggle MQTT on/off | +| Broker | Hostname or IP of your MQTT broker | +| Port | Default `1883` | +| User / Pass | Broker credentials | +| Prefix | Topic prefix (default `m1730`) | + +### Home Assistant auto-discovery + +On connect, the device publishes discovery payloads to `homeassistant/number/…/config`. Each meter appears in HA as a **Number** entity with `min`/`max` taken from the meter's configured physical range and a step of 0.1. The entities are grouped under a single HA device named after the hostname. + +### Value mapping + +Internally the firmware works with a 0–100 % duty value. MQTT publishes and receives **physical values** — the percentage is transparently mapped to the meter's Min–Max range: + +``` +physicalValue = percentage / 100 × (rangeMax - rangeMin) + rangeMin +``` + +For example, with Min=0 and Max=50, the slider at 50 % publishes `25.0` to MQTT, and a command of `25.0` on the `/set` topic moves the slider to 50 %. + +### Topics + +| Direction | Topic | Description | +|-----------|-------|-------------| +| Published | `/meter//current` | Current meter value in physical units (retained) | +| Subscribed | `/meter//current/set` | Set meter value in physical units | +| Published | `/status` | `online: true` on connect, `online: false` as LWT | + +`` is the zero-based meter index. + +### Reconnection + +The firmware probes the broker TCP port before attempting a full MQTT connect. If the broker is unreachable, it retries every 30 seconds without blocking the web server. + +### Example HA automation + +```yaml +automation: + - alias: Show solar power on meter + trigger: + platform: state + entity_id: sensor.solar_power_w + action: + service: number.set_value + target: + entity_id: number.m1730_solar + data: + value: "{{ trigger.to_state.state | float | round(1) }}" + +# Values are in physical units — if the meter Min=0, Max=3000, the HA +# number entity directly accepts watts, no conversion needed. +``` + +--- + +## HTTP API + +### `GET /set?i=&v=` + +Immediately moves meter `` to `` (0–100), updates PWM, persists the value to flash, and publishes to MQTT. Used by the live slider on the web UI. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `i` | integer | Meter index (0-based) | +| `v` | float | Value 0–100 | + +Returns `200 OK` on success, `400` on bad input. + +### `GET /` + +Returns the full HTML configuration page. + +### `POST /config` + +Saves all configuration from the HTML form and re-applies PWM to all meters. Responds with the updated configuration page. + +--- + +## Configuration reference + +Config is stored as JSON in LittleFS at `/config.json`. + +```json +{ + "hostname": "m1730", + "mqtt": { + "enabled": true, + "host": "192.168.1.10", + "port": 1883, + "user": "ha", + "pass": "secret", + "prefix": "m1730" + }, + "meters": [ + { + "pin": 4, + "maxD": 72.5, + "current": 45.0, + "name": "Solar", + "unit": "W", + "rangeMin": 0.0, + "rangeMax": 3000.0 + } + ] +} +``` + +The file is written by the web UI and should not need manual editing. To reset to factory defaults, delete the file or erase flash with `pio run --target erase`. diff --git a/src/main.cpp b/src/main.cpp index 0221ab7..ab24ae9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,7 +19,8 @@ struct MeterConfig { float currentValue; char name[32] = ""; char unit[16] = ""; - float range = 100.0; + float rangeMin = 0.0; + float rangeMax = 100.0; }; struct MqttConfig { @@ -78,7 +79,8 @@ static void loadConfig() { meters[i].currentValue = m["current"] | 0.0f; strlcpy(meters[i].name, m["name"] | "", sizeof(meters[i].name)); strlcpy(meters[i].unit, m["unit"] | "", sizeof(meters[i].unit)); - meters[i].range = m["range"] | 100.0f; + meters[i].rangeMin = m["rangeMin"] | m["range"] | 0.0f; + meters[i].rangeMax = m["rangeMax"] | m["range"] | 100.0f; } Serial.printf("[CFG] loaded hostname=%s meters=%d mqtt_en=%d\n", hostname, meterCount, mqttCfg.enabled); } @@ -103,7 +105,8 @@ static void saveConfig() { m["current"] = meters[i].currentValue; m["name"] = meters[i].name; m["unit"] = meters[i].unit; - m["range"] = meters[i].range; + m["rangeMin"] = meters[i].rangeMin; + m["rangeMax"] = meters[i].rangeMax; } File f = LittleFS.open("/config.json", "w"); @@ -140,6 +143,20 @@ static void applyMeters() { } } +// --------------------------------------------------------------------------- +// Range mapping +// --------------------------------------------------------------------------- + +static float pctToPhysical(float pct, int idx) { + return pct / 100.0f * (meters[idx].rangeMax - meters[idx].rangeMin) + meters[idx].rangeMin; +} + +static float physicalToPct(float physical, int idx) { + float range = meters[idx].rangeMax - meters[idx].rangeMin; + if (range == 0) return 0; + return constrain((physical - meters[idx].rangeMin) / range * 100.0f, 0, 100); +} + // --------------------------------------------------------------------------- // MQTT // --------------------------------------------------------------------------- @@ -166,10 +183,10 @@ static void mqttCallback(char* topic, byte* payload, unsigned int len) { String suffix = t.substring(slash); if (suffix == "/current/set") { - Serial.printf("[MQTT] set meter%d current=%.1f\n", idx, val); - meters[idx].currentValue = val; + Serial.printf("[MQTT] set meter%d physical=%.1f\n", idx, val); + meters[idx].currentValue = physicalToPct(val, idx); if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) { - float pct = val / 100.0f * meters[idx].maxDuty / 100.0f; + float pct = meters[idx].currentValue / 100.0f * meters[idx].maxDuty / 100.0f; ledcWrite(idx, constrain((int)(pct * 1023), 0, 1023)); } saveConfig(); @@ -181,7 +198,8 @@ static void mqttPublishCurrent(int idx) { if (!mqttCfg.enabled || !mqttClient.connected()) return; char topic[128], val[16]; snprintf(topic, sizeof(topic), "%s/meter/%d/current", mqttCfg.prefix, idx); - snprintf(val, sizeof(val), "%.1f", meters[idx].currentValue); + float physical = pctToPhysical(meters[idx].currentValue, idx); + snprintf(val, sizeof(val), "%.1f", physical); bool ok = mqttClient.publish(topic, val, true); Serial.printf("[MQTT] publish topic=%s val=%s ok=%d\n", topic, val, ok); } @@ -206,8 +224,8 @@ static void mqttPublishDiscovery() { doc["name"] = name; doc["state_topic"] = stat; doc["command_topic"] = stat + "/set"; - doc["min"] = 0; - doc["max"] = 100; + doc["min"] = meters[i].rangeMin; + doc["max"] = meters[i].rangeMax; doc["step"] = 0.1; if (strlen(meters[i].unit) > 0) doc["unit_of_measurement"] = meters[i].unit; @@ -343,14 +361,16 @@ static void handleRoot() { String nameVal = escHtml(meters[i].name); String unitVal = escHtml(meters[i].unit); - char rangeStr[8]; - dtostrf(meters[i].range, 1, 1, rangeStr); + char rMinStr[8], rMaxStr[8]; + dtostrf(meters[i].rangeMin, 1, 1, rMinStr); + dtostrf(meters[i].rangeMax, 1, 1, rMaxStr); meterRows += "
Meter " + String(i) + ""; meterRows += "
"; meterRows += "
"; meterRows += "
"; - meterRows += "
"; + meterRows += "
"; + meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += ""; @@ -489,8 +509,10 @@ static void handleConfig() { strlcpy(newMeters[i].name, server.arg(pf + "name").c_str(), sizeof(newMeters[i].name)); if (server.hasArg(pf + "unit")) strlcpy(newMeters[i].unit, server.arg(pf + "unit").c_str(), sizeof(newMeters[i].unit)); - if (server.hasArg(pf + "range")) - newMeters[i].range = server.arg(pf + "range").toFloat(); + if (server.hasArg(pf + "rangeMin")) + newMeters[i].rangeMin = server.arg(pf + "rangeMin").toFloat(); + if (server.hasArg(pf + "rangeMax")) + newMeters[i].rangeMax = server.arg(pf + "rangeMax").toFloat(); if (server.hasArg(pf + "maxD")) newMeters[i].maxDuty = server.arg(pf + "maxD").toFloat(); if (server.hasArg(pf + "cur")) @@ -500,8 +522,8 @@ static void handleConfig() { meterCount = newCount; memcpy(meters, newMeters, sizeof(meters)); for (int i = 0; i < meterCount; i++) - Serial.printf("[HTTP] meter%d pin=%d name=%s unit=%s range=%.1f maxD=%.1f cur=%.1f\n", - i, meters[i].pin, meters[i].name, meters[i].unit, meters[i].range, meters[i].maxDuty, meters[i].currentValue); + Serial.printf("[HTTP] meter%d pin=%d name=%s unit=%s range=[%.1f,%.1f] maxD=%.1f cur=%.1f\n", + i, meters[i].pin, meters[i].name, meters[i].unit, meters[i].rangeMin, meters[i].rangeMax, meters[i].maxDuty, meters[i].currentValue); saveConfig(); attachMeters();