No description
Find a file
2026-08-16 18:31:16 +02:00
.vscode Arduino 3 later commit 2026-07-06 10:51:38 +02:00
data Fix C6 build (toolchain + partitions), logo aspect, dynamic title 2026-08-13 00:27:39 +02:00
src Fix calibration for inverted meter ranges 2026-08-16 16:06:52 +02:00
.gitignore Base code with wifi and captive portal 2026-06-14 16:37:57 +02:00
card.txt Update docs and cards for 0.5kHz PWM and Log scale 2026-08-16 18:31:16 +02:00
card_de.txt Update docs and cards for 0.5kHz PWM and Log scale 2026-08-16 18:31:16 +02:00
card_fr.txt Update docs and cards for 0.5kHz PWM and Log scale 2026-08-16 18:31:16 +02:00
LICENSE Range mapped, documentation added 2026-06-15 00:10:53 +02:00
M1730-Scale_paths.svg Scale as SVG with text (requires GOST font) and paths added 2026-06-15 10:37:19 +02:00
M1730-Scale_text.svg Scale as SVG with text (requires GOST font) and paths added 2026-06-15 10:37:19 +02:00
platformio.ini Fix C6 build (toolchain + partitions), logo aspect, dynamic title 2026-08-13 00:27:39 +02:00
README.md Update docs and cards for 0.5kHz PWM and Log scale 2026-08-16 18:31:16 +02:00

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

Each meter needle is controlled by a PWM signal on a GPIO pin. The firmware maps a physical value to a PWM duty cycle using a per-meter calibration table (reference value → duty %). With no calibration points defined, it falls back to a plain linear mapping scaled by the Max Duty percentage you calibrate for full-scale deflection. All settings are stored in LittleFS (/config.json) so they survive reboots.

With a calibration table:

duty %  =  linear interpolation between the surrounding (value, duty) points,
           falling back to the plain linear mapping outside the table

Without one (linear fallback):

needle position = (currentValue / 100) × (maxDuty / 100) × 1023 ticks

PWM runs at 0.5 kHz with 10-bit resolution (01023).

Physical range mapping

Each meter has configurable Min / Max values that define its physical range (e.g. 050 A). The firmware normalises to 0100 % 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 07)
PWM freq 0.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.

Here's a list of suggested current-limiting resistors:

Meter full-scale (I_fs) Typical R_coil V across coil @ FS Series R (calc) Nearest E12 (≤calc) Pin current @ 100% duty
50 µA 15 kΩ ~0.2 V 62 kΩ 56 kΩ 54 µA
100 µA 0.52 kΩ ~0.15 V 31 kΩ 27 kΩ 114 µA
200 µA 0.51 kΩ ~0.15 V 15.7 kΩ 15 kΩ 203 µA
500 µA 100500 Ω ~0.15 V 6.3 kΩ 5.6 kΩ 551 µA
1 mA 50200 Ω ~0.15 V 3.15 kΩ 2.7 kΩ 1.14 mA
5 mA 2080 Ω ~0.2 V 620 Ω 560 Ω 5.34 mA
10 mA 540 Ω ~0.2 V 310 Ω 270 Ω 11.4 mA

Building and flashing

The project uses PlatformIO.

# 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://<nhostname>.local (default http://m1730.local) (mDNS, works on most local networks)
  • http://<IP address> (shown in the serial monitor on boot)

Factory reset (forgot password)

If you lose the web UI password, reset the device 5 times in quick succession (press the EN button or cycle power 5× within a few seconds — before the boot completes). The firmware resets all settings to factory defaults except meter pin assignments and calibration (maxDuty), which are preserved. On acknowledgement, all connected meters sweep 0→100→0.

Serial output shows the progress:

[BOOT] reset count 1/5
[BOOT] reset count 2/5
...
[BOOT] reset count 5/5, triggering factory reset
[BOOT] factory reset triggered!
...
[BOOT] factory reset complete, restarting...

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 (<hostname>.local) and the MQTT device name. Saved across reboots.

Meters

Use the Meters dropdown to add or remove meters (18). 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. 050 A, 03000 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 (0100). Calibrate this so the needle just reaches full deflection
Log scale When checked, the value-to-position mapping is logarithmic instead of linear (useful for wide-range meters such as power). Only available when Min is greater than 0
Calibration Optional table of reference values → duty % to correct for a non-linear meter scale. Between entries the duty is interpolated linearly; values outside the table's reference range revert to the simple linear Max Duty mapping. Leave empty (or use fewer than two points) to use that linear mapping everywhere
Output slider Moves the needle live (0100 % 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 0100 % duty value. MQTT publishes and receives physical values — the percentage is transparently mapped to the meter's MinMax 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 <prefix>/meter/<n>/current Current meter value in physical units (retained)
Subscribed <prefix>/meter/<n>/current/set Set meter value in physical units
Published <prefix>/status online: true on connect, online: false as LWT

<n> 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

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=<index>&v=<value>

Immediately moves meter <index> to <value> (0100), updates PWM, and publishes to MQTT. Used by the live slider on the web UI. The value is applied in RAM only and is not persisted to flash — it is lost on reboot. Use the config page or a retained MQTT /set command to set a value that survives a restart.

Parameter Type Description
i integer Meter index (0-based)
v float Value 0100

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.

GET /export

Returns only the meters configuration (pins, names, units, ranges, max duty, calibration tables) as a JSON array. Network/MQTT/auth settings are not included. Used by the "Export to box" button on the /backup page.

POST /import

Replaces the meters configuration from a JSON array (same shape as /export). Payload is sent as a form field named json. The input is validated before being applied: field bounds are clamped, arrays/strings are length-limited, numbers must be finite, ranges must be ordered, and calibration points must be strictly increasing. Invalid input returns 400 with an explanation and changes nothing. Network/MQTT/auth settings are left untouched. On success it redirects back to the main configuration page.

GET /backup

Dedicated page with the meters export/import UI.

curl -u admin:pass -d "json=[{\"pin\":4,\"maxD\":72.5,\"name\":\"Solar\",\"unit\":\"W\",\"rangeMin\":0,\"rangeMax\":3000}]" http://m1730.local/import

Configuration reference

Config is stored as JSON in LittleFS at /config.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,
      "name": "Solar",
      "unit": "W",
      "rangeMin": 0.0,
      "rangeMax": 3000.0,
      "logS": false,
      "cal": [
        { "ref": 0.0, "duty": 0.0 },
        { "ref": 1500.0, "duty": 35.0 },
        { "ref": 3000.0, "duty": 72.5 }
      ]
    }
  ]
}

The file is written by the web UI and should not need manual editing. To reset to factory defaults (keeping pin/calibration), reset the device 5 times in quick succession. To erase everything including flash, use pio run --target erase.