Skip to content
Sourceful Energy

Docs · Local API

Zap Local API

The local REST API of the Zap gateway, as of firmware 7.0.5.

This page documents Zap firmware 7.0.5. Newer firmware may add, change or remove endpoints; check zap.firmwareVersion in GET /api/system. Every DER payload and DER name on this page is data model v2.

No authentication. The local API takes no credentials. Anyone who can reach the Zap on its network can call every endpoint, including those that change Wi-Fi, the MQTT broker and the device list. Keep the Zap on a trusted network.

This page covers the common routes. GET /api lists every route the running firmware has.

Contents#

  1. Base URL
  2. Discovery
  3. System & Network
  4. Device Management
  5. Modbus
  6. Diagnostics
  7. Identity & Security
  8. OTA Updates
  9. Utilities
  10. Reference

Base URL#

REST paths are under /api/ on port 80, for example http://192.168.1.100/api/system. The same routes answer over BLE; GET /api is HTTP only.

Outside /api/: GET / and GET /index.html (dashboard), GET /zap.logg (recent logs), GET /favicon.ico (/spiffs/favicon.ico, 404 when missing). Other GET paths return 404.

  • /api responses carry Access-Control-Allow-Origin: *; OPTIONS on any path answers a CORS preflight. A /api/... response closes the connection.
  • HTTP reads at most 16 KB of a request body.
  • No endpoint reads a query string, and a /api/... path is matched with it: /api/system?x=1 is a 404.
  • An endpoint that reboots does so after its response is sent, on every transport.

Discovery#

mDNS#

Once it is on a network and BLE onboarding has ended, the Zap announces itself over mDNS. On Wi-Fi BLE stops 60 s after the link comes up, so until then use the IP address.

WhatValue
HostnameThe device id, zap-0000a1b2c3d4e5f6.local, the same as zap.deviceId in GET /api/system and name in GET /api/name
Aliaszap.local, claimed only if no other device on the network answers to it within 2 s. With several Zaps, use the device-unique name
Service_http._tcp, port 80, with the device id as instance name
TXT recordsid (device id), ver (firmware version), mac (Wi-Fi MAC)

GET/(dashboard)#

Embedded page at http://<deviceId>.local/ (e.g. zap-0000a1b2c3d4e5f6.local), also GET /index.html, sent with Content-Encoding: gzip. It polls GET /api/system then GET /api/devices every 5 s (paused while the tab is hidden), and GET /api/devices/{sn}/status for each expanded device card.

GET/zap.logg#

text/plain recent logs: the RAM ring plus leftover /spiffs/boot_logs.bin. Does not clear either buffer. Live stream is POST /api/system/log_stream to MQTT gateways/{id}/logs/raw. HTTP, MQTT, and BLE request lines name the method, path, byte count, and top-level JSON keys. Values are omitted.

GET/api#

List local REST operations (dashboard header link). HTTP only; a method other than GET or OPTIONS is 405 {"error":"method"}.

Built at request time from the firmware's own route table, with paths written as /api/devices/{sn}/.... A registered route without a description is listed with group Other and summary Live.

Response (200), streamed JSON:

JSON
{
  "title": "Zap firmware REST",
  "source": "EndpointRegistry",
  "endpoints": [
    {
      "method": "GET",
      "path": "/api/system",
      "group": "System",
      "summary": "Info",
      "registered": true
    }
  ]
}

registered is true when the registry resolves the path. GET /api itself is served outside the registry and shows false. An example body request appears on routes such as POST /api/devices and POST /api/modbus/read.


System & Network#

GET/api/system#

System info, heap, network, uptime.

  • temperatureCelsius: only when the SoC temperature sensor returns a reading, including a real 0.0.
  • memoryKb: heap in KB; min is the lowest free heap since boot, largest the largest free block.
  • processesAverage is always 0. logging is the log stream setting (POST /api/system/log_stream).
  • zap.flashSizeMB: the flash size in the image header, not the chip's; omitted if it cannot be read. zap.firmwareVersion is the release number (version.h); zap.firmwareBuild is the running image's git describe (also printed as App version at boot).
  • zap.network is the object of GET /api/network.

Response (200):

JSON
{
  "timeUtcSec": 1697515200,
  "uptimeSeconds": 123456,
  "temperatureCelsius": 41.5,
  "backend": "mainnet",
  "memoryKb": {
    "total": 4096,
    "free": 2560,
    "used": 1536,
    "percentUsed": 37.5,
    "min": 2048,
    "largest": 1024
  },
  "processesAverage": {
    "last1min": 0,
    "last5min": 0,
    "last15min": 0
  },
  "logging": {
    "enabled": true,
    "level": 4
  },
  "zap": {
    "deviceId": "zap-0000a1b2c3d4e5f6",
    "platform": "srcful-zap-eth",
    "cpuFreqMHz": 160,
    "flashSizeMB": 4,
    "sdkVersion": "v5.5",
    "firmwareVersion": "7.0.5",
    "firmwareBuild": "v7.0.5",
    "publicKey": "a1b2c3...",
    "network": {
      "wifiMac": "AA:BB:CC:DD:EE:01",
      "wifiStatus": "connected",
      "wifiConnected": true,
      "localIP": "192.168.1.100",
      "ssid": "MyNetwork",
      "rssi": -45,
      "bssid": "a4:2b:b0:5e:13:7c",
      "channel": 6,
      "ethMac": "AA:BB:CC:DD:EE:02",
      "ethStatus": "connected",
      "ethConnected": true,
      "ethAvailable": true,
      "ethLocalIP": "192.168.1.249",
      "interfaces": {
        "wifi": {
          "mac": "AA:BB:CC:DD:EE:01",
          "connected": true,
          "ip": "192.168.1.100",
          "ssid": "MyNetwork",
          "rssi": -45,
          "bssid": "a4:2b:b0:5e:13:7c",
          "channel": 6
        },
        "eth": {
          "mac": "AA:BB:CC:DD:EE:02",
          "connected": true,
          "available": true,
          "ip": "192.168.1.249"
        }
      },
      "internetConnected": true,
      "mqttConnected": true,
      "cloudPath": { "stage": "online", "error": null, "checked": true }
    }
  }
}

POST/api/system/reboot#

Reboots 5 s after the response is sent.

Response (200):

JSON
{
  "status": "success",
  "message": "System is rebooting in 5 seconds"
}

POST/api/system/factory-reset#

Deletes every device config, clears both MQTT broker strings and the Wi-Fi credentials, publishes a factory_reset event, and reboots 2 s after the response. The backend choice is kept.

Response (200):

JSON
{
  "message": "Factory reset successful. Rebooting in 2 seconds."
}

Errors:

  • 500 - {"message": "Failed to clear devices"}; MQTT and Wi-Fi are left as they were, no reboot. The factory_reset event has already been published

POST/api/system/log_stream#

Turn log streaming to MQTT gateways/{gateway_id}/logs/raw on or off. The setting is saved in /spiffs/system_config.json, but every boot turns streaming on again: at the saved level when it was on, else at INFO (4). Off lasts until the next reboot.

Request:

JSON
{
  "enabled": true,
  "level": 4
}

level is optional (default 4). Levels: 1=ERROR, 2=WARN, 3=OK, 4=INFO, 5=DEBUG, 6=VERBOSE. A body without enabled changes nothing. The response enabled is whether streaming is on now.

Response (200):

JSON
{
  "enabled": true,
  "status": "ok"
}

GET/api/network#

Network connectivity status. The same object is zap.network in GET /api/system.

Response (200) on a board without Ethernet (P1, HAN):

JSON
{
  "wifiMac": "AA:BB:CC:DD:EE:01",
  "wifiStatus": "connected",
  "wifiConnected": true,
  "localIP": "192.168.1.100",
  "ssid": "MyNetwork",
  "rssi": -45,
  "bssid": "a4:2b:b0:5e:13:7c",
  "channel": 6,
  "interfaces": {
    "wifi": {
      "mac": "AA:BB:CC:DD:EE:01",
      "connected": true,
      "ip": "192.168.1.100",
      "ssid": "MyNetwork",
      "rssi": -45,
      "bssid": "a4:2b:b0:5e:13:7c",
      "channel": 6
    }
  },
  "internetConnected": true,
  "mqttConnected": true,
  "cloudPath": { "stage": "online", "error": null, "checked": true }
}
  • interfaces has the same state as the flat fields, per interface (mac, connected, ip).
  • wifiMac is always present. wifiStatus is connected or disconnected. localIP / ssid (nested ip / ssid) appear only while Wi-Fi is connected; rssi, bssid and channel (the same names nested) only while the station is also associated with an access point.
  • bssid (lower-case colon hex), channel and rssi are the access point the station is on, from the same reading as uplink.wifi in GET /api/gateway/status: on a mesh or behind extenders, bssid names which access point of the SSID it is. ssid here is the configured SSID.
  • A board with Ethernet adds ethMac, ethStatus, ethConnected, ethAvailable (always true), ethLocalIP (while connected) and interfaces.eth (mac, connected, available, ip); see the GET /api/system example. Without Ethernet hardware none of them appear: "eth" in interfaces tells support.
  • internetConnected is true when a link is up and a DNS lookup of google.com succeeds (the result is kept 30 s).

cloudPath — the first step on the way to the broker that has not succeeded, and why, if the Zap knows. It is not the uplink of the gateway status (buffering, backlog): that is the link once it is up.

stageMeaningerror (null while the step is still in progress)
wifiJoining the access pointA POST /api/wifi reason from the last disconnect (no_ap_found, wrong_password, security_unsupported, auth_failed, assoc_failed, signal_lost)
ipJoined, waiting for DHCPnull
ntpWaiting for the clock. MQTT starts only once it is set: by SNTP (pool.ntp.org, time.nist.gov, UDP 123), or, from 30 s after the link came up, by the Date header of a plain HTTP request to the backend's host (TCP 80, retried from 30 s up to every 10 min)ntp_no_reply once the first wait (20 s) after a Wi-Fi connect ended without time; SNTP keeps trying
dnsThe broker hostname did not resolvedns_failed
brokerTCP and TLS to the broker (TCP 8883)tcp_failed (port blocked or host unreachable), tls_failed
authThe broker refused the CONNECTtoken_rejected (JWT refused), broker_refused
onlineMQTT session up (same as mqttConnected)null

checked is true once the Zap has checked the way to the broker since its link came up: the BLE-window pre-flight below finished (until MQTT starts, which clears it), an MQTT attempt failed, or MQTT is up. It is false without a link. A failed MQTT attempt from before the link dropped still counts once it is back: that failure is cleared only when MQTT connects or stops. While it is false, broker with error: null can still turn into dns_failed or tcp_failed; once it is true, broker with error: null means the broker is reachable. After POST /api/wifi a client can poll this over BLE until checked or until a stage has an error.

The error of a later stage is from the last connect attempt and is cleared when MQTT connects. A session that was up and drops (Wi-Fi lost) is not a failed attempt and sets no error.

While BLE is up (onboarding), MQTT does not run (memory). The Zap then runs a pre-flight once per Wi-Fi link (not on Ethernet): it resolves the broker and opens and closes a TCP connection to it, without TLS. Over BLE the app therefore sees wifi, ip, ntp, dns / dns_failed and broker / tcp_failed; when the broker is reachable, broker with error: null. tls_failed, auth and online show only after BLE has ended and MQTT has run (over the LAN, or as the gateway coming online in the cloud).

GET/api/wifi#

Get WiFi status and the results of the last scan. This does not scan; call GET /api/wifi/scan for a fresh list.

Response (200):

JSON
{
  "scanning": false,
  "scanned_at_ms": 124099,
  "scan_age_ms": 189,
  "scan_count": 7,
  "connected": "Network1",
  "ssids": ["Network1", "Network2"]
}
FieldMeaning
scanningA scan is running right now
scanned_at_msUptime (ms) when the last scan finished; null before the first scan since boot
scan_age_msAge of that scan in ms; null before the first scan
scan_countLists stored since boot; a new value means a new list
connectedConfigured SSID while connected, otherwise empty
ssidsUnique SSIDs of the last scan, sorted by name. When there are many, only the strongest that fit are listed (260 bytes) so the response fits one 512-byte BLE notification

POST/api/wifi#

Set WiFi credentials and connect.

Request:

JSON
{
  "ssid": "MyNetwork",
  "psk": "mypassword"
}

Response (200):

JSON
{
  "status": "success",
  "message": "WiFi credentials updated and connected"
}

Errors (HTTP and the BLE EGWTP/1.1 status line):

  • 400 - Empty body (Empty request body), or missing ssid or psk (Missing credentials)
  • 500 - WiFi connect failed. The body names the reason:
JSON
{
  "status": "error",
  "reason": "no_ap_found",
  "wifiReason": 201,
  "message": "WiFi network not found"
}

wifiReason is the ESP-IDF wifi_err_reason_t of the last disconnect (0 if none came). reason (WifiFailReason) is one of:

reasonWhenmessage
no_ap_foundSSID not seen: wrong name, 5 GHz only (the Zap is 2.4 GHz only), or out of range (IDF 201, 212)WiFi network not found
wrong_password4-way handshake failed (IDF 15, 204, 14)Wrong WiFi password (credentials rejected)
security_unsupportedNo AP with a security mode the Zap accepts (IDF 210, 211)WiFi network security mode not supported
auth_failedThe AP rejected authentication; with WPA3-SAE usually the password (IDF 202, 2)WiFi authentication failed (check the password)
assoc_failedThe AP refused the association, or another IDF reasonAccess point refused to connect
signal_lostBeacons stopped (IDF 200)WiFi signal too weak
no_ipJoined the AP, but DHCP gave no addressJoined the WiFi network but got no IP address
timeoutNothing came back before the wait ended (also IDF 3, 8)WiFi connection timed out
internal_errorThe Zap could not start the attemptWiFi could not be started on the Zap

For apps that read message instead of reason, only the wrong_password and auth_failed messages contain "password".

Success (joined and an IP address) is 200 on HTTP and EGWTP/1.1 200 OK on BLE, with WiFi credentials updated and connected. If the station already has an IP on that same SSID and password, the link stays up so the HTTP client receives the 200.

DELETE/api/wifi#

Erase the saved WiFi credentials. The response goes out on the live link; 5 s later the Zap drops Wi-Fi and starts BLE onboarding, the same state as a boot with no saved credentials.

Response (200):

JSON
{
  "status": "success",
  "message": "WiFi credentials cleared, disconnecting in 5 seconds"
}

Errors:

  • 500 - Failed to clear WiFi credentials; the link stays up

GET/api/wifi/scan#

Run a WiFi scan now and return its results. Every request returns a scan that finished after it came in; a scan already running is waited for first. If a connect attempt is in progress, the request waits for it to end and then scans; auto-connect starts no new attempt while a scan request waits. The request blocks until the scan is done, about 3 s over HTTP and 5 s over BLE (BLE and WiFi share the radio), plus up to 20 s of waiting. It works during BLE onboarding and does not depend on the Wi-Fi status task. The same list is then also in GET /api/wifi.

Response (200):

JSON
{
  "status": "done",
  "count": 13,
  "scanning": false,
  "scanned_at_ms": 124099,
  "scan_age_ms": 7,
  "scan_count": 7,
  "ssids": ["Network1", "Network2"]
}

count is the number of unique SSIDs in the scan (at most 20 AP records are read); ssids can be shorter when the list is cut to fit BLE (see GET /api/wifi). The freshness fields are the same as in GET /api/wifi.

Errors (HTTP and the EGWTP/1.1 status line):

StatusstatusreasonWhen
409busyscan_in_progressAnother scan was still running after the 20 s wait. Retry in a few seconds
409busyconnectingA connect attempt was still in progress after the 20 s wait
503errornot_initializedWiFi is not up yet
504errortimeoutThe WiFi driver timed out
500errorscan_failedThe WiFi driver could not start or read the scan
JSON
{"status": "busy", "reason": "scan_in_progress", "scanning": true, "scanned_at_ms": 10000, "scan_age_ms": 2000, "scan_count": 3}

POST/api/ble/stop#

Stop BLE about 3 s after the request: a stop is requested 1 s after it, and the main loop shuts BLE down once that request is more than 2 s old.

Response (200):

JSON
{
  "status": "success",
  "message": "BLE stopping..."
}

GET/api/system/backend#

Get the current backend environment.

Response (200):

JSON
{
  "backend": "testnet"
}

Values: "devnet", "testnet", "mainnet".

GET/api/system/mqtt#

Get the MQTT broker strings.

Response (200):

JSON
{
  "mqtt_connection_str": "mqtt://broker.example.com:1883",
  "mqtt_connection_fallback_str": ""
}

Both are empty strings when none is set. An empty mqtt_connection_str makes the Zap use the active backend's broker. Each successful connect saves that broker's URI as mqtt_connection_fallback_str.

POST/api/system/mqtt#

Set the MQTT broker strings. Saves them in /spiffs/system_config.json and reboots 2 s after the response.

Request:

JSON
{
  "mqtt_connection_str": "mqtt://broker.example.com:1883",
  "mqtt_connection_fallback_str": "mqtt://fallback.example.com:1883"
}

At least one of the two is required; a missing one is left as it is. When a connect fails and both are set and differ, the Zap saves the fallback (the last broker that connected, unless one was posted since) as mqtt_connection_str and reboots after 2 s.

Response (200):

JSON
{
  "status": "success",
  "message": "MQTT config updated, reboot scheduled"
}

Errors:

  • 400 - Empty body, or neither string given

DELETE/api/system/mqtt#

Clear both broker strings (in /spiffs/system_config.json, despite the message) and reboot 2 s after the response. The Zap then uses the active backend's broker.

Response (200):

JSON
{
  "status": "success",
  "message": "MQTT config removed from NVS, reboot scheduled"
}

Device Management#

GET/api/devices/supported#

List supported device profiles, grouped by device_type. count is the number of profiles. profile is the value to post as profile; a P1 or HAN meter is chosen by type instead.

Response (200), lists shortened:

JSON
{
  "count": 18,
  "batteries": [
    {
      "display_name": "Pixii PowerShaper",
      "profile": "pixii",
      "device_type": "battery",
      "connection_types": ["modbus_tcp", "modbus_rtu"]
    }
  ],
  "ev_chargers": [],
  "energy_meters": [
    {
      "display_name": "Fronius Smart Meter",
      "profile": "fronius_smart_meter",
      "device_type": "energy_meter",
      "connection_types": ["modbus_tcp", "modbus_rtu"]
    },
    {
      "display_name": "P1 Meter",
      "profile": "p1_meter",
      "device_type": "energy_meter",
      "connection_types": ["p1_uart"]
    }
  ],
  "inverters": [
    {
      "display_name": "Sungrow",
      "profile": "sungrow",
      "device_type": "inverter",
      "connection_types": ["modbus_tcp", "modbus_rtu"]
    },
    {
      "display_name": "Solis",
      "profile": "solis",
      "device_type": "inverter",
      "connection_types": ["modbus_tcp", "modbus_rtu"]
    }
  ],
  "v2x_chargers": [
    {
      "display_name": "Ambibox",
      "profile": "ambibox",
      "device_type": "v2x_charger",
      "connection_types": ["mqtt"]
    },
    {
      "display_name": "Ferroamp V2X Charger",
      "profile": "ferroamp_v2x",
      "device_type": "v2x_charger",
      "connection_types": ["mqtt"]
    }
  ]
}

GET/api/devices/types#

A fixed list of connection types and their parameters, for a device form. connection_types is an object keyed by type. Each parameter has name, display_name, type and required, and may have default and description.

Response (200), one type shown:

JSON
{
  "connection_types": {
    "modbus_tcp": {
      "description": "Modbus TCP connection",
      "parameters": [
        { "name": "ip", "display_name": "IP Address", "type": "string", "required": true },
        { "name": "port", "display_name": "Port", "type": "integer", "required": true, "default": 502 },
        { "name": "unit_id", "display_name": "Unit ID", "type": "integer", "required": true, "default": 1 },
        { "name": "profile", "display_name": "Device Profile", "type": "string", "required": true }
      ]
    }
  }
}
KeyParameters as listed (* = required: true, default in brackets)
modbus_tcpip*, port* (502), unit_id* (1), profile*
modbus_rtubaud_rate* (9600), unit_id* (1), profile*, parity (integer, 0; 0=None, 1=Odd, 2=Even), data_bits (8), stop_bits (1)
p1_uartmanufacturer, baud_rate* (115200), data_bits* (8), parity (string, none), stop_bits (1)
mqttbroker_host*, broker_port (1883), username, password, client_id, use_tls (false), tls_ca

The list is static. It does not list rediscover_after_min, and P1 serial settings are optional when posted (POST /api/devices).

GET/api/devices#

List all configured devices. Each entry is the device's config (as in GET /api/devices/{sn}) followed by runtime fields. A runtime field the firmware does not know is omitted, never sent as a placeholder:

  • connected: boolean – current connection state
  • last_harvest: unix ms timestamp of the newest solar, battery, meter or inverter data (omitted if none yet; EV charger data does not count)
  • read_time_ms: integer – measured read time of the last harvest (the data model's read_time_ms); omitted when nothing was measured, e.g. a device whose data is pushed over MQTT
  • last_error: string – the last failure the shared connect and harvest paths saw: connect_failed (a connect attempt failed) or connection_lost (the device dropped during a harvest). Kept after a later success; omitted if none since the device was loaded
  • last_error_ts: unix ms timestamp of last_error (sent with it). A failure before the clock was set (early boot) carries milliseconds since boot, like any timestamp taken then
  • name: string – display name of the device's profile (Solis, SDM630 Meter, P1 Meter, as in GET /api/devices/supported); omitted when the profile is unknown
  • framing: string – serial framing a P1/HAN meter was detected on, e.g. 115200 8N1; omitted until a port is detected
  • disconnects: integer – outages since the device was loaded (boot, add or PUT). An outage opens on a failure while none is open and closes on the next successful connect, however many reconnect attempts it takes. The outages themselves are in GET /api/devices/{sn}/status
  • connected_since: unix ms timestamp of the connect that started the current connected period (a drop the harvest did not see, followed by a successful first reconnect, keeps the earlier value); sent only while connected
  • path: string – the path to the configuration file in SPIFFS (e.g. spiffs/devices/6178e775.json)
  • host_mac: string – modbus_tcp and mqtt only: MAC of the host (Modbus TCP server or MQTT broker), e.g. AA:BB:CC:DD:EE:03. The Zap reads it from its ARP table after a successful connect and keeps it in the stored config. Omitted until one is known, and for a host behind a router (no ARP entry). It changes when other hardware answers at that address
  • ders (part of the config): per-DER entries (solar/battery/meter/inverter/ev_charger_port) with the publish flag (enabled: the Zap publishes that DER's data to the backend) and the DER config: solar installed_power_W_dc, battery rated_power_W_dc and capacity_Wh_dc, inverter rated_power_W_ac, meter location (see DER Field Reference)

A saved config whose device is not loaded is listed as saved, plus connected: false, disconnects: 0 and path; one without sn is listed as saved only.

Response (200):

JSON
{
  "count": 2,
  "devices": [
    {
      "type": "modbus_tcp",
      "ip": "192.168.1.60",
      "port": 502,
      "unit_id": 1,
      "profile": "sungrow",
      "device_type": "inverter",
      "sn": "INV003SIM03",
      "ders": [
        {
          "type": "solar",
          "enabled": false,
          "installed_power_W_dc": 9500
        },
        {
          "type": "battery",
          "enabled": false,
          "rated_power_W_dc": 5000,
          "capacity_Wh_dc": 10000
        },
        {
          "type": "meter",
          "enabled": false
        },
        {
          "type": "inverter",
          "enabled": true,
          "rated_power_W_ac": 10000
        }
      ],
      "connected": true,
      "last_harvest": 1761832393075,
      "read_time_ms": 472,
      "last_error": "connect_failed",
      "last_error_ts": 1761830000000,
      "name": "Sungrow",
      "disconnects": 1,
      "connected_since": 1761830012000,
      "path": "spiffs/devices/6178e775.json"
    },
    {
      "type": "mqtt",
      "profile": "ambibox",
      "device_type": "v2x_charger",
      "sn": "ambibox_192.168.1.50",
      "broker_host": "192.168.1.50",
      "broker_port": 1883,
      "username": "user",
      "password": "secret",
      "client_id": "esp32_local",
      "use_tls": false,
      "ders": [
        {
          "type": "ev_charger_port",
          "enabled": false
        }
      ],
      "connected": false,
      "name": "Ambibox",
      "disconnects": 0,
      "path": "spiffs/devices/0aa8abcd.json"
    }
  ]
}

POST/api/devices#

Add a device. Modbus and MQTT devices connect now and are saved once they report a serial number. A smart-meter UART device (p1_uart; one per Zap) is saved at once and detects its port and serial framing in the background. Publishes a device_added event.

Before it starts, the request waits up to 15 s for the first backend MQTT connect and any HTTPS client to finish, then needs a largest free block of 30720 bytes.

Modbus TCP:

JSON
{
  "type": "modbus_tcp",
  "ip": "192.168.1.60",
  "port": 502,
  "unit_id": 1,
  "profile": "sungrow"
}
  • rediscover_after_min (optional): after this many minutes unreachable (default 20, 0 off, at most 10080), the Zap scans its subnet for the device's port, keeps hosts whose ARP MAC is the stored host_mac, and moves the device to the one that answers with its serial number (event device_host_ip_changed). Stored only when given.

Modbus RTU (RS-485):

JSON
{
  "type": "modbus_rtu",
  "baud_rate": 9600,
  "unit_id": 1,
  "profile": "sungrow",
  "parity": 0
}

Note: On zap-p1 and zap-han, Modbus RTU uses UART0 (TX=GPIO7, RX=GPIO5, RTS=GPIO6); on zap-eth-s3, UART2 (TX=GPIO39, RX=GPIO36, RTS=GPIO38); on zap-ultra-mini, UART2 (TX=GPIO3, RX=GPIO1, RTS=GPIO2). zap-eth has no Modbus RTU pins; an rtu target there is not rejected and runs on UART1, P1's UART, with pins -1. On zap-p1, zap-han and zap-eth-s3, P1 and RTU run simultaneously. Several RTU devices share that one RS-485 bus, each with its own unit_id. They may use different baud_rate or parity; the bus is switched per transaction.

P1 UART:

JSON
{
  "type": "p1_uart",
  "manufacturer": "MyMeter"
}

P1 UART Configuration Options:

  • type: p1_uart, for a meter on the P1 port or the HAN port. The Zap detects which
  • baud_rate: Integer > 0 (e.g., 9600, 115200)
  • data_bits: 5, 6, 7, 8
  • parity: "none", "even", "odd"
  • stop_bits: 1, 2, "1.5"
  • rx_pin, tx_pin, dtr_pin, dtr_out_pin, led_pin: GPIO numbers, default the board's P1 pins. A pin on the status LED or button is a 400 Invalid device configuration

Serial settings are optional; an unsupported value is ignored. Explicit values are tried first, followed by the firmware's supported P1 profiles. zap-p1 probes the inverted P1 input only. zap-han also probes the non-inverted HAN input (GPIO 10, 2400 8N1). Once detected, device JSON includes detected_type (p1_uart or han_uart).

MQTT (Ferroamp EnergyHub):

JSON
{
  "type": "mqtt",
  "profile": "ferroamp",
  "broker_host": "192.168.1.70",
  "broker_port": 1883,
  "username": "extapi",
  "password": "your_password"
}

MQTT (Ferroamp V2X Charger):

JSON
{
  "type": "mqtt",
  "profile": "ferroamp_v2x",
  "broker_host": "192.168.1.70",
  "broker_port": 1883,
  "username": "dc2",
  "password": "your_password"
}

MQTT (Ambibox EV Charger):

JSON
{
  "type": "mqtt",
  "profile": "ambibox",
  "broker_host": "192.168.1.70",
  "broker_port": 1884,
  "username": "external-ems",
  "password": "your_password"
}

Without username / password an MQTT device uses its profile's defaults; Ambibox also has a default broker_host.

MQTT client id and one broker (every MQTT profile):

  • Each MQTT device connects with its own client id. Without client_id (absent, null or "") it is zap- and 16 hex digits of a hash of the Zap's id, the profile, broker_host (lower case) and broker_port, for example zap-1a2b3c4d5e6f7a8b: the same after every reboot, different for every profile and broker. The serial log names it when it creates the client.
  • client_id, when set, is used as it is: a string of at most 63 bytes without control characters. Messages: client_id must be a string, client_id is longer than 63 bytes, client_id may not hold control characters.
  • One broker (the same broker_host, ignoring case, and broker_port) takes several MQTT devices of different profiles, for example a Ferroamp EnergyHub (ferroamp) and its DC2 (ferroamp_v2x). It does not take two of the same profile: the profiles' topics carry no device serial, so both would read and control the same device. Nor two with the same client id: the broker keeps one session per client id (MQTT 3.1.1 §3.1.4). Either is a 409 before the Zap connects, naming the device it conflicts with:
JSON
{ "message": "Conflicts with device AMBI-12345: the same profile on the same MQTT broker", "conflicts_with": "AMBI-12345" }

The other reason is the same MQTT client_id on the same broker.

MQTT over TLS (any MQTT profile):

JSON
{
  "type": "mqtt",
  "profile": "ambibox",
  "broker_host": "charger.local",
  "use_tls": true,
  "tls_ca": "-----BEGIN CERTIFICATE-----\nMIIDBDCCAeyg...\n-----END CERTIFICATE-----\n"
}
  • use_tls: true or false (default false); any other value is a 400. With TLS and no broker_port the port is 8883.
  • The Zap connects to the resolved IP of broker_host (mDNS .local names too), but SNI and the certificate check use broker_host as configured: the server certificate must name it. Verification is never skipped, and a TLS device never falls back to plain MQTT.
  • tls_ca: the CA of that broker, required with use_tls: true. PEM text with one or more certificates, at most 4096 bytes. The Zap trusts exactly these certificates for this broker; it has no public roots for device brokers. Not allowed without use_tls: true; null is the same as no tls_ca. Every block must be a certificate that parses: a key, other text or an empty string is a 400. It is stored with the device config and returned by GET /api/devices and GET /api/devices/{sn} (a CA certificate is public).
  • One TLS handshake runs at a time (backend MQTT, OTA check, registration, firmware download, device brokers). A device's TLS connect waits up to 10 s for another one to finish, and does not start while the largest free block is under 16 KB. After a failed TLS attempt the device waits 30 s before the next.
  • Heap: a TLS device costs much more than a plain one: about 25 KB of free heap while connected, and 22–36 KB during its handshake. Check the free heap before adding TLS devices to a loaded Zap.
  • A failed connect is logged with its reason: server certificate has expired, server certificate is not signed by a trusted CA, server certificate does not name the broker host, server certificate is not valid yet (is the clock set?), server certificate rejected, tls_ca could not be loaded, broker closed the connection in the TLS handshake (not a TLS port?), TLS handshake failed, broker refused the login (username or password), broker refused the connection, broker closed the connection, TCP connect failed, connect timed out, no answer in time (no connect or disconnect event came), and, when no cause is known, TLS connect failed or connect failed.

A rejected MQTT broker setting is a 400 that names the field, for example:

JSON
{ "message": "tls_ca requires use_tls true", "field": "tls_ca" }

The messages: broker_host is required, broker_port must be 1-65535, client_id must be a string, client_id is longer than 63 bytes, client_id may not hold control characters, use_tls must be true or false, use_tls requires tls_ca, the CA of the broker, tls_ca must be a string, tls_ca is empty, tls_ca requires use_tls true, tls_ca is longer than 4096 bytes, tls_ca may hold only PEM certificates, tls_ca holds no certificate, tls_ca has a certificate without an END line, tls_ca has a certificate that is not valid Base64, tls_ca has a certificate that is not DER, tls_ca has a certificate that does not parse.

DER config (optional, every device type): ders holds the same entries as POST /api/devices/{sn}/ders (type, enabled, installed_power_W_dc, rated_power_W_dc, capacity_Wh_dc, rated_power_W_ac, location). They are applied on top of the DER types the device reports (Modbus: the first harvest; MQTT: the profile; P1: the meter), with the validation of POST /api/devices/{sn}/ders. Unlike there, an entry for a type that was not detected is stored as posted; a detected type without an entry starts unpublished (inverter: published) with no ratings. A config from GET /api/devices/{sn} can be posted back as is:

JSON
{
  "type": "modbus_tcp",
  "ip": "192.168.1.60",
  "port": 502,
  "unit_id": 1,
  "profile": "solis",
  "ders": [
    { "type": "solar", "enabled": true, "installed_power_W_dc": 0 },
    { "type": "battery", "enabled": true, "rated_power_W_dc": 5000, "capacity_Wh_dc": 10000 },
    { "type": "meter", "enabled": true },
    { "type": "inverter", "enabled": true, "rated_power_W_ac": 5000 }
  ]
}

Response (201), here for a Modbus device posted without ders. message is Device added and connected (Modbus), Device added successfully (MQTT) or Smart meter added; automatic port detection started (P1/HAN):

JSON
{
  "message": "Device added and connected",
  "sn": "INV003SIM03",
  "ders": [
    {
      "type": "solar",
      "enabled": false,
      "installed_power_W_dc": 0
    },
    {
      "type": "battery",
      "enabled": false,
      "rated_power_W_dc": 0,
      "capacity_Wh_dc": 0,
      "min_soc_fract": 0.05,
      "max_soc_fract": 1,
      "adjustable": false
    },
    {
      "type": "meter",
      "enabled": false
    }
  ]
}

Battery SoC fields (read-only):

  • min_soc_fract / max_soc_fract: the battery's one SoC window as fractions (0.0–1.0), used for its charge/discharge limits and its available energy alike: the device's own limits from its own registers (Sungrow, Solis, Pixii) when they can be read and are valid, otherwise the default 0.05 / 1.00. 0 / 0 and a swapped pair are invalid. The window is never config: it is not stored, and values sent in a request are ignored.
  • adjustable: true if the window of the latest reading came from the device, false if it is the default.

Errors:

  • 400 - No type (Missing 'type' field in configuration), an invalid config (Invalid device configuration; an MQTT broker setting names the field, see MQTT over TLS), or an invalid ders array (see POST /api/devices/{sn}/ders); nothing is added
  • 409 - A second P1/HAN device (Smart meter UART device already exists); Device already exists or conflicts, with sn (same serial number, same TCP ip:port:unit_id, or same RTU unit_id on the bus); or an MQTT device of the same profile or client id on the same broker (see MQTT client id and one broker)
  • 503 - Connect failed, no serial number read, or the largest free block is under 30720 bytes (largest_free_block and required_block in the body)

PUT/api/devices/{sn}#

Update a device's connection configuration and/or DER metadata. The handler connects to the device with the new config and checks the serial number (a Modbus device reports it; an MQTT device takes sn from the body when it has one, otherwise the serial the device reports over MQTT), then swaps the device and publishes a device_updated event. The existing DER metadata is kept; if the request body includes a ders array, the fields its entries carry are applied on top, with the validation of POST /api/devices/{sn}/ders; unlike there, an entry for a type the device does not have adds it. A battery entry's min_soc_fract / max_soc_fract / adjustable are ignored: the SoC window is never config.

A typical workflow is to GET /api/devices/{sn}, modify the returned JSON (e.g., change IP or DER settings), and PUT it back.

Example: PUT /api/devices/INV003SIM03

Request (same format as POST /api/devices, optionally with ders):

JSON
{
  "type": "modbus_tcp",
  "ip": "192.168.1.61",
  "port": 502,
  "unit_id": 1,
  "profile": "sungrow",
  "ders": [
    { "type": "solar", "enabled": true, "installed_power_W_dc": 12000.0 },
    { "type": "battery", "enabled": true, "rated_power_W_dc": 5000.0, "capacity_Wh_dc": 10000.0 },
    { "type": "meter", "enabled": false },
    { "type": "inverter", "enabled": true, "rated_power_W_ac": 10000.0 }
  ]
}

Response (200):

JSON
{
  "message": "Device updated",
  "sn": "INV003SIM03"
}

Note: The ders array is optional. DER types, enabled flags and ratings that the request does not carry are preserved unchanged, so a PUT without ders updates only the connection parameters.

Note: An MQTT config is validated as in POST /api/devices. To turn TLS off, send "use_tls": false without tls_ca (or with "tls_ca": null): a tls_ca without TLS is a 400.

Errors:

  • 400 - Invalid config (an MQTT broker setting names the field, see POST /api/devices) or an invalid ders array (see POST /api/devices/{sn}/ders)
  • 404 - Device not found (not loaded)
  • 409 - Serial number mismatch: the serial the new config yields is not {sn} (expected_sn and actual_sn in the body); or an MQTT config that conflicts with another device (same profile or client id on the same broker). The old device remains intact
  • 500 - Failed to add updated device (the old one is already removed)
  • 503 - Connect failed with the new config, or no serial number read (old device remains intact)

GET/api/devices/{sn}#

Get the full device configuration (connection parameters + DER metadata) by serial number. The returned JSON can be modified and sent back via PUT /api/devices/{sn}.

Example: GET /api/devices/INV003SIM03

Response (200):

JSON
{
  "type": "modbus_tcp",
  "ip": "192.168.1.61",
  "port": 502,
  "unit_id": 1,
  "profile": "sungrow",
  "device_type": "inverter",
  "sn": "INV003SIM03",
  "ders": [
    { "type": "solar", "enabled": true, "installed_power_W_dc": 12000 },
    { "type": "battery", "enabled": true, "rated_power_W_dc": 5000, "capacity_Wh_dc": 10000 },
    { "type": "meter", "enabled": true },
    { "type": "inverter", "enabled": true, "rated_power_W_ac": 10000 }
  ]
}

The battery's SoC window is not part of the configuration (see GET /api/devices/{sn}/ders). A Modbus TCP or MQTT config also has host_mac once known, and Modbus TCP rediscover_after_min when set. An MQTT config has password as stored.

Errors:

  • 404 - Device not found (not loaded)

GET/api/devices/{sn}/status#

Runtime status of a loaded device with its recent outages. It has the same status fields as the device's GET /api/devices entry, plus outages: the last 5 outages, newest first. GET /api/devices carries only the count (disconnects). The dashboard fetches this for an expanded device card.

Each outage:

  • start: when the failure that opened it happened (unix ms; ms since boot before the clock is set)
  • end: when the device connected again; omitted while the outage is ongoing
  • cause: connection_lost (the device dropped during a harvest) or connect_failed (a connect attempt failed while no outage was open: at boot, or after a drop the harvest did not see, such as an MQTT broker drop)
  • attempts: connect attempts during the outage, the one that ended it included

The history lives in RAM and starts empty when the device is loaded (boot, add or PUT). Novacore gets a device's reachability from the gateway status and the device_unreachable / device_reachable events (GET /api/gateway/status).

Example: GET /api/devices/12345678/status

Response (200):

JSON
{
  "sn": "12345678",
  "connected": true,
  "last_harvest": 1791542400418,
  "read_time_ms": 118,
  "last_error": "connect_failed",
  "last_error_ts": 1791542139394,
  "name": "SDM630 Meter",
  "disconnects": 2,
  "connected_since": 1791542330000,
  "outages": [
    { "start": 1791541277000, "end": 1791542330000, "cause": "connection_lost", "attempts": 205 },
    { "start": 1791530000000, "end": 1791530005000, "cause": "connection_lost", "attempts": 1 }
  ]
}

Errors:

  • 404 - Device not found (not loaded)

DELETE/api/devices/{sn}#

Remove a device by serial number: it is disconnected and its stored config deleted, also a stored config that did not load. Publishes a device_removed event.

A device's config is stored under its serial number. When a Modbus device reports another serial number on connect, the Zap moves the stored config to the reported one. Until then, {sn} may be either serial number; the reported one also removes a copy stored under it.

Example: DELETE /api/devices/INV003SIM03

Response (200):

JSON
{ "message": "Device removed" }

Errors:

  • 404 - Device not found, neither loaded nor stored

DELETE/api/devices#

Remove all devices: disconnects them and deletes every stored device config.

Example: DELETE /api/devices

Response (200):

JSON
{ "message": "All devices removed" }

Errors:

  • 500 - Failed to clear devices (e.g. SPIFFS error)

GET/api/devices/{sn}/data/json#

Latest device data snapshot. Each DER object is the same payload the Zap publishes on MQTT: data model v2. Every field of the DER type is present, unread values are null, every W/V/A/VA/Wh field has an _ac / _dc postfix. The envelope keys are the v2 DER types (solar, battery, meter, inverter, ev_charger_port), the same as each payload's type; a DER the last harvest did not read is left out. version is the data model version (v2). format is json, p1_uart / han_uart for a smart meter, sdm630 for the SDM630 and garo for GARO. The publish flags (enabled) do not hide anything here.

The inverter object is the inverter's AC output stage, from profiles that read it (Solis hybrid, Deye, Fronius, SolarEdge, Pixii, SMA): W_ac (- delivering into the site, + absorbing), phase V/A/W (SMA has no phase current), apparent_VA_ac, Hz, temperature_C (the inverter's own temperature) and rated_power_W_ac (the inverter entry's config, else what the device reports). reactive_var_ac and the energy counters are null until a profile reads them.

The meter object's apparent_VA_ac / reactive_var_ac come from the SDM630 (total VA 0x0038, VAr 0x003C) and, for reactive power only, from P1 / HAN (3.7.0 − 4.7.0). Reactive power is signed like W_ac: + from the grid side through the meter, − delivered. A meter that reads neither sends null.

Example: GET /api/devices/INV003SIM03/data/json (Sungrow, PV and battery)

Response (200):

JSON
{
  "solar": {
    "type": "solar",
    "make": "sungrow",
    "timestamp": 1761832393075,
    "read_time_ms": 472,
    "W_ac": null,
    "W_dc": -2500,
    "mppt": [
      { "V_dc": 410.5, "A_dc": -6.1, "W_dc": -2504.05 },
      { "V_dc": 0, "A_dc": 0, "W_dc": 0 }
    ],
    "total_generation_Wh_ac": null,
    "total_generation_Wh_dc": 18342100,
    "lower_limit_W_ac": null,
    "lower_limit_W_dc": null
  },
  "battery": {
    "type": "battery",
    "make": "sungrow",
    "timestamp": 1761832393075,
    "read_time_ms": 472,
    "W_ac": null,
    "W_dc": -500,
    "V_ac": null,
    "V_dc": 48.2,
    "A_ac": null,
    "A_dc": -10.4,
    "soc_nom_fract": 0.85,
    "soh_fract": null,
    "temperature_C": 24.5,
    "total_charge_Wh_ac": null,
    "total_charge_Wh_dc": 4200000,
    "total_discharge_Wh_ac": null,
    "total_discharge_Wh_dc": 3900000,
    "available_charge_Wh_ac": null,
    "available_charge_Wh_dc": null,
    "available_discharge_Wh_ac": null,
    "available_discharge_Wh_dc": null,
    "upper_limit_W_ac": null,
    "upper_limit_W_dc": 5000,
    "lower_limit_W_ac": null,
    "lower_limit_W_dc": -5000,
    "setpoint_W_ac": null,
    "setpoint_W_dc": null
  },
  "version": "v2",
  "format": "json"
}

Errors:

  • 204 - No harvest data yet ({"message": "No data available yet"})
  • 404 - Device not found

GET/api/devices/{sn}/data/raw#

The OBIS strings of a P1 or HAN meter's last decoded frame. No other device type has raw data: they answer 204.

Example: GET /api/devices/p1m-0000a1b2c3d4e5f6/data/raw (a P1 device's serial number is by default p1m- and the Zap's id without zap-)

Response (200) - P1 Meter:

JSON
{
  "format": "p1_uart",
  "data": {
    "ts": 1770302863728,
    "device_id": "p1m-0000a1b2c3d4e5f6",
    "obis": [
      "1-3:0.2.8(50)",
      "0-0:1.0.0(251021091842S)",
      "1-0:1.8.1(004121.646*kWh)",
      "1-0:1.8.2(004416.112*kWh)",
      "1-0:2.8.1(002029.530*kWh)",
      "1-0:2.8.2(004760.021*kWh)",
      "1-0:1.7.0(00.000*kW)",
      "1-0:2.7.0(00.445*kW)",
      "1-0:32.7.0(238.1*V)",
      "1-0:52.7.0(236.6*V)",
      "1-0:72.7.0(237.9*V)"
    ]
  }
}

Fields:

  • format: the port the meter was read on, p1_uart or han_uart
  • data: ts (unix ms), device_id (the device's serial number), obis (array of OBIS strings)

Errors:

  • 204 - No raw data available yet
  • 404 - Device not found

GET/api/devices/{sn}/data/debug#

For a P1 or HAN meter, the hex dump and ASCII of the last frame that failed to decode.

Example: GET /api/devices/p1m-0000a1b2c3d4e5f6/data/debug

Response (200) - P1 Meter with failed frame:

JSON
{
  "device_type": "p1_uart",
  "sn": "p1m-0000a1b2c3d4e5f6",
  "last_failed_ts": 8123456,
  "last_failed_type": 0,
  "last_failed_size": 56,
  "last_failed_hex": "2F 58 4D 58 35 4C 47 42 42 46 46 46 46 31 30 30 31 32 33 34 35 36 37 38 ...",
  "last_failed_ascii": "/XMX5LGBBFFFF10012345678..."
}

Response (200) - P1 Meter with no failed frames:

JSON
{
  "device_type": "p1_uart",
  "sn": "p1m-0000a1b2c3d4e5f6",
  "last_failed_hex": "",
  "message": "No failed frames recorded yet"
}

Response (200) - Non-P1 device:

JSON
{
  "sn": "INV003SIM03",
  "message": "Debug info not available for this device type"
}

Fields:

  • device_type: connection type, p1_uart or han_uart
  • last_failed_ts: milliseconds since boot when the frame failed to decode
  • last_failed_type: frame type (0=ASCII, 1=HDLC, 2=MBUS)
  • last_failed_size: bytes kept, at most 1024
  • last_failed_hex: those bytes as XX hex
  • last_failed_ascii: the same bytes, non-printable ones as .

A failed frame is also logged in hex at DEBUG level. Once the meter's port is detected, it is also sent as a p1_frame_decode_failed event with the whole frame in hex.

Errors:

  • 404 - Device not found

POST/api/devices/{sn}/types#

Set the publish flag of DERs the device has. Only the DER types in the request change; an entry without enabled sets it to false. A detected DER starts unpublished, an inverter published. POST /api/devices/{sn}/ders sets the same flags, but leaves enabled as it is when an entry has none and rejects an unknown or duplicate type.

Request:

JSON
{
  "ders": [
    { "type": "solar", "enabled": true },
    { "type": "battery", "enabled": false }
  ]
}

Response (200):

JSON
{ "message": "Types updated", "sn": "INV003SIM03" }

"message": "No changes" when no flag changed.

Errors:

  • 400 - ders missing or not an array (Invalid 'ders' format)
  • 404 - Device not found
  • 500 - Failed to save updated config
  • 405 - A POST /api/devices/{sn} path other than /types or /ders

DER type values are the data model v2 DER types: solar, battery, meter, inverter, ev_charger_port.

GET/api/devices/{sn}/ders#

Publish flags and DER config of a loaded device, plus the battery's read-only SoC window. The other fields can be set with POST /api/devices/{sn}/ders.

Response (200):

JSON
{
  "sn": "INV003SIM03",
  "ders": [
    {
      "type": "solar",
      "enabled": true,
      "installed_power_W_dc": 8500
    },
    {
      "type": "battery",
      "enabled": false,
      "rated_power_W_dc": 4800,
      "capacity_Wh_dc": 9600,
      "min_soc_fract": 0.20,
      "max_soc_fract": 0.80,
      "adjustable": true
    },
    {
      "type": "meter",
      "enabled": false,
      "location": "grid"
    },
    {
      "type": "inverter",
      "enabled": true,
      "rated_power_W_ac": 10000
    },
    {
      "type": "ev_charger_port",
      "enabled": true
    }
  ]
}

DER Field Reference:

DER TypeFieldUnitDescription
solarenabled-Enable/disable publishing
solarinstalled_power_W_dcWInstalled panel power (DC); 0 = not set
batteryenabled-Enable/disable publishing
batteryrated_power_W_dcWBattery rated power (DC); 0 = not set
batterycapacity_Wh_dcWhBattery capacity; 0 = not set
batterymin_soc_fractfractLower SoC limit (0.0–1.0), read-only: device-read, else 0.05
batterymax_soc_fractfractUpper SoC limit (0.0–1.0), read-only: device-read, else 1.00
batteryadjustable-true if the SoC window was read from the device
meterenabled-Enable/disable publishing
meterlocation-Where the meter measures: grid, generation or load; left out when not set. A P1 or HAN meter defaults to grid
inverterenabled-Enable/disable publishing (default true)
inverterrated_power_W_acWThe inverter's rated AC power; 0 = not set. Caps the battery's discharge limit: -min(battery rated, inverter rated - PV)
ev_charger_portenabled-Enable/disable publishing

The names are the data model v2 metadata names, except the battery's adjustable, which is the Zap's own.

A device with solar or a battery has an inverter entry for its rating, also when its profile reads no inverter stage (Sungrow, Huawei); the entry is listed once a rating is set, an inverter entry was posted (rating 0 without one), or a harvest read the inverter (Solis hybrid, Deye, Fronius, SolarEdge, Pixii).

Errors:

  • 404 - Device not found

POST/api/devices/{sn}/ders#

Update publish flags and static DER metadata in one request. For solar entries, supply installed_power_W_dc in watts. For battery entries, supply rated_power_W_dc (W) and capacity_Wh_dc (Wh). For the inverter entry, supply rated_power_W_ac (W); Sungrow battery control needs it with installed_power_W_dc. For a meter entry, location is grid, generation, load or null (not set). min_soc_fract / max_soc_fract are accepted and ignored: the SoC window is the device's own, else 5–100 %, never config. enabled is optional; omit it to leave the publish flag as it is. Only the DERs in the payload change. An entry for a DER type the device does not have is ignored, except inverter on a device with solar or a battery, which adds it.

The same entries are accepted in POST /api/devices and PUT /api/devices/{sn}, with the same validation (there, an entry for a missing type is stored). A request is checked whole before anything changes. The 400 body names the problem, for example {"message": "Invalid DER field", "type": "battery", "field": "capacity_Wh_dc"}:

messageWhen
'ders' is not an array / 'ders' is not a valid arrayders is not a JSON array
DER entry is not an object / DER entry has no typeAn entry is not an object, or has no type
Unknown DER type / Duplicate DER typetype is not a v2 DER type, or is listed twice
Invalid DER fieldenabled is not a boolean; installed_power_W_dc (solar), rated_power_W_dc or capacity_Wh_dc (battery), rated_power_W_ac (inverter) or an old name is not a finite number >= 0; or a meter's location is not one of the names or null

Request:

JSON
{
  "ders": [
    {
      "type": "solar",
      "installed_power_W_dc": 10200
    },
    {
      "type": "battery",
      "enabled": true,
      "rated_power_W_dc": 5000,
      "capacity_Wh_dc": 12800
    },
    {
      "type": "inverter",
      "rated_power_W_ac": 10000
    }
  ]
}

Response (200):

JSON
{ "message": "DERs updated", "sn": "INV003SIM03" }

"message": "No changes" when nothing changed; the config is then not saved.

Errors:

  • 400 - Missing 'ders' array, or an invalid ders array (above); nothing is changed
  • 404 - Device not found
  • 500 - Failed to save updated config

Modbus#

Read registers on any Modbus device the Zap can reach, configured or not: a debug API for bring-up and support. The Zap's own harvest does not use it.

Both endpoints take one JSON object naming a target, a function code and an address:

TargetFieldsConnection
A configured device"device": "<sn>"The device's own connection and unit id, under its lock (a harvest in progress is waited for, up to 3 s)
Modbus TCP"tcp": {"ip": "192.168.1.60", "port": 502}, "unit_id" 0-255Connected for this request, closed after it
Modbus RTU (the board's RS-485 port)"rtu": {"baud": 9600, "parity": "none", "data_bits": 8, "stop_bits": 1}, "unit_id" 1-247The shared bus, with this baud and parity for this request; configured RTU devices on the bus keep theirs

Exactly one target is given; unit_id goes with tcp and rtu only. rtu.parity (none, odd, even) is optional, default none. data_bits (7, 8) and stop_bits (1, 2) are validated but not applied: the bus always runs 8 data bits and 1 stop bit.

function_code is always given, so the request says exactly what goes on the wire: there is no default and no fallback to another code. An unknown field is a 400 that names it ("Unknown field: register"), which catches typos and the fields of the removed endpoints.

Errors (every failure has this body):

JSON
{ "error": "exception", "message": "The device answered with a Modbus exception", "exception_code": 2 }
StatuserrorMeaning
400bad_requestInvalid request; message says which field. Also a device that is not a Modbus device
404not_foundNo configured device with that serial number
502exceptionThe device answered with a Modbus exception; exception_code is its code (1 illegal function, 2 illegal data address, 3 illegal data value, ...)
502not_connectedThe configured device is not connected, or the request could not be sent
502connect_failedThe TCP connection or the RS-485 port could not be opened
502protocolAn answer that is not a valid Modbus reply (CRC, length, ids)
503busyThe configured device stayed locked for 3 s
504timeoutNo answer in time, also a TCP connection closed while waiting (for RTU: also a unit id nobody answers to)

POST/api/modbus/read#

FC 0x03 (holding) or FC 0x04 (input), 1-125 registers in one request.

FieldRequiredDescription
targetYesdevice, or tcp / rtu with unit_id (see above)
function_codeYes3 (holding) or 4 (input)
addressYesFirst register, 0-65535
countYesRegisters, 1-125
decodeNo{"type", "endianness", "scale"}: also return the registers as one value

decode.type: u16, i16 (1 register), u32, i32, f32 (2), u64, i64 (4) or str (any count, two ASCII characters per register, trailing NULs dropped, other non-printable bytes as ?). count must match the type. decode.endianness (big: first register is the most significant word; little: the least) is required for a type of 2 or 4 registers, since word order differs between vendors; for str it is the byte order in each register (default big). decode.scale multiplies the value. An integer without a scale stays an integer. A value that is not finite (an f32 NaN or Inf) is null.

Example: a configured device's battery power (Solis, 33149-33150, signed 32-bit)

JSON
{
  "device": "SOLIS0000000001",
  "function_code": 4,
  "address": 33149,
  "count": 2,
  "decode": { "type": "i32", "endianness": "big" }
}

Response (200):

JSON
{
  "function_code": 4,
  "address": 33149,
  "count": 2,
  "words": [65535, 61436],
  "type": "i32",
  "value": -4100
}

Example: a meter on the RS-485 port that is not configured (SDM630 L1 voltage)

JSON
{
  "rtu": { "baud": 38400, "parity": "none" },
  "unit_id": 1,
  "function_code": 4,
  "address": 0,
  "count": 2,
  "decode": { "type": "f32", "endianness": "big" }
}

words is always returned; type and value only with decode.


Diagnostics#

GET/api/gateway/status#

The gateway status snapshot: the health of each device the Zap reads and its uplink, as the data model v2 GatewayStatus. The same bytes the Zap publishes to Novacore on gateways/{gw}/status/json/v2 every 30 s and on a change.

Response (200):

JSON
{
  "v": 2,
  "ts": 1791826871201,
  "versions": [{ "name": "firmware", "version": "7.0.5" }],
  "devices": [
    { "device": "12345678", "state": "unreachable", "control": "l1",
      "since_at": 1791826730000, "last_ok_at": 1791826700000, "error": "modbus timeout" },
    { "device": "p1m-0000a1b2c3d4e5f6", "state": "ok", "control": "l1",
      "since_at": 1791826857073, "last_ok_at": 1791826863639, "error": null }
  ],
  "storage": null,
  "uplink": { "buffering": false, "backlog": 0, "interface": "wifi",
    "wifi": { "ssid": "MyNetwork", "bssid": "a4:2b:b0:5e:13:7c", "channel": 6, "rssi_dBm": -45 } }
}
  • devices: every loaded device from its first outcome, and every stored config that did not load at boot, sorted by device; one with no known hardware id is null, last. null while the harvest worker is stuck (no device's state is known).
  • state: ok, stale or unreachable; since_at is when it began, last_ok_at the last good read (null when none since the device was loaded). Epoch ms.
  • error: why a device is not ok, from the data-model vocabulary (modbus timeout, connection refused, no data, ...); null when ok.
  • storage is null, and uplink.buffering / backlog are false / 0: the Zap has no data partition and keeps no telemetry while offline.
  • uplink.interface: the interface of the default route, which Novacore's traffic takes: wifi or ethernet; null when no default route has an address. With both up ESP-IDF routes over Wi-Fi (route priority 100, Ethernet 50).
  • uplink.wifi: the access point the Wi-Fi station is associated with, { ssid, bssid, channel, rssi_dBm } (bssid lower-case colon hex; ssid as the access point reports it, a byte that is not UTF-8 written as ?), also when the traffic goes over Ethernet; null when associated with none. Read when the snapshot is written, as GET /api/network reads it.
  • control is always l1.

Errors:

  • 503 - Gateway status not available yet: the clock is not set, or the device list stayed busy

POST/api/network/port-check#

Check if a TCP port is open on a remote host. Useful for verifying network connectivity to Modbus devices or brokers before adding them.

Request:

JSON
{
  "host": "192.168.1.60",
  "port": 502,
  "timeout": 2000
}

timeout (ms) is optional, default 2000. The request blocks until the connect ends.

Response (200):

JSON
{
  "host": "192.168.1.60",
  "port": 502,
  "open": true
}

Errors:

  • 400 - Missing required field: host or Missing required field: port

POST/api/network/scan#

Scan the local subnet for hosts with open TCP ports, for example to find Modbus devices. It scans the subnet of the Wi-Fi address, else the Ethernet address: at most one /24 (a wider netmask scans the Zap's own /24), without the network, broadcast and own addresses. It returns every host that answered. A second scan while one is running is refused.

Request:

JSON
{
  "ports": [502, 80],
  "timeout": 500,
  "batch_size": 12
}
FieldRequiredDefaultDescription
portsNo[502]TCP ports to check: the first 8 in 1-65535 are used; none valid gives [502]
timeoutNo500Time budget per batch of probes in ms, waited in 100 ms rounds, clamped to 50-5000
batch_sizeNo12Concurrent connections, clamped to 1-12. The value is cut to 8 bits first, so 256 becomes 1

Response (200):

JSON
{
  "status": "success",
  "subnet": "192.168.1.0/24",
  "hosts_found": 3,
  "hosts": [
    { "ip": "192.168.1.1", "ports": [80] },
    { "ip": "192.168.1.60", "ports": [502] },
    { "ip": "192.168.1.70", "ports": [502, 80] }
  ]
}

Errors:

  • 409 - {"status": "busy", "reason": "scan_in_progress"}: a scan is already running. It is not queued
  • 503 - Network interface not available: neither Wi-Fi nor Ethernet has an address, or the probe list could not be allocated

Identity & Security#

GET/api/crypto#

Get device identity. deviceName is always software_zap; serialNumber is the device id; publicKey is the 64-byte P-256 public key (X‖Y) as 128 hex characters.

Response (200):

JSON
{
  "deviceName": "software_zap",
  "serialNumber": "zap-0000a1b2c3d4e5f6",
  "publicKey": "a1b2c3..."
}

POST/api/crypto/sign#

Sign with the device's private key (ECDSA P-256 over SHA-256). The signed string is message|nonce|timestamp|serial, without message| when there is no message. nonce is generated by the Zap; timestamp defaults to the current UTC time as YYYY-MM-DDTHH:MM:SSZ; a timestamp over 63 characters is ignored and the default used. sign is r‖s as 128 hex characters. The response message is not escaped: a " or \ in the message or timestamp can make the response invalid JSON or add keys to it.

Request:

JSON
{
  "message": "optional-message-content",
  "timestamp": "optional-timestamp"
}

Response (200):

JSON
{
  "sign": "5f3a...",
  "message": "optional-message-content|1804289383|optional-timestamp|zap-0000a1b2c3d4e5f6"
}

Errors:

  • 400 - Pipe character (|) in message or timestamp

GET/api/name#

Get the device id.

Response (200):

JSON
{
  "name": "zap-0000a1b2c3d4e5f6"
}

OTA Updates#

GET/api/ota/status#

The OTA task's state. progress is 0-100. result is the outcome of the last update and is returned once: the next call omits it.

Response (200):

JSON
{
  "status": "success",
  "in_progress": false,
  "progress": 0,
  "result": { "success": false, "message": "Invalid content length: -1", "status_code": 500 }
}

Utilities#

GET/api/debug#

Get debug report.

Response (200):

JSON
{
  "status": "success",
  "report": {
    "uptime_sek": 3600123,
    "p1CfgIx": -1,
    "failedFrames": 0,
    "successFrames": 0,
    "totalFrames": 0,
    "deviceId": "",
    "deviceModel": "",
    "freeHeap": 75000,
    "minFreeHeap": 52000,
    "resetReason": 3,
    "resetReasonName": "software"
  }
}
  • uptime_sek: milliseconds since boot, despite the name. It is 32-bit and wraps to 0 after about 49.7 days.
  • resetReason: why the previous run ended, esp_reset_reason() (ESP-IDF esp_reset_reason_t): 1 power on, 3 software restart (esp_restart(), e.g. a reboot the Zap scheduled), 4 panic (crash), 5 interrupt watchdog, 6 task watchdog, 7 other watchdog, 9 brownout. The boot log has the same as reset reason: <name> (<n>).
  • resetReasonName: resetReason as a word: power_on, external_pin, software, panic, interrupt_watchdog, task_watchdog, watchdog, deep_sleep, brownout, sdio, usb, jtag, efuse, power_glitch, cpu_lockup, or unknown.
  • meterDataBuffer (hex) while a P1/HAN meter's serial buffer exists.

POST/api/echo#

Echo the request body as a string, exactly as sent.

Request:

JSON
{"any":"content"}

Response (200):

JSON
{
  "echo": "{\"any\":\"content\"}"
}

Reference#

Status Codes#

CodeMeaning
200OK — request succeeded
201Created — resource was created
202Accepted — async operation queued
204No Content — no data available yet (the body still has a message)
400Bad Request — invalid JSON, missing fields, or bad parameter values
404Not Found — no such resource, or no route for this path and method ({"status":"error","message":"Not found"})
405Method Not Allowed — GET /api with another method; POST /api/devices/{sn} without /types or /ders
409Conflict — resource already exists or operation conflicts
500Internal Server Error — operation failed
502Bad Gateway — Modbus connection or reply failure
503Service Unavailable — device busy, connection failed, or insufficient memory
504Gateway Timeout — WiFi scan driver timeout, or no Modbus answer in time

Examples#

Add TCP device:

Shell
curl -X POST http://192.168.1.100/api/devices \
  -H "Content-Type: application/json" \
  -d '{
    "type": "modbus_tcp",
    "ip": "192.168.1.50",
    "port": 502,
    "unit_id": 1,
    "profile": "sungrow"
  }'

Add MQTT device (Ferroamp):

Shell
curl -X POST http://192.168.1.100/api/devices \
  -H "Content-Type: application/json" \
  -d '{
    "type": "mqtt",
    "profile": "ferroamp",
    "broker_host": "192.168.1.70",
    "broker_port": 1883,
    "username": "extapi",
    "password": "your_password"
  }'

Add P1 UART device:

Shell
curl -X POST http://192.168.1.100/api/devices \
  -H "Content-Type: application/json" \
  -d '{
    "type": "p1_uart",
    "manufacturer": "MyMeter",
    "baud_rate": 115200,
    "data_bits": 8,
    "parity": "none",
    "stop_bits": 1
  }'

Update device config (IP change):

Shell
curl -X PUT http://192.168.1.100/api/devices/INV003SIM03 \
  -H "Content-Type: application/json" \
  -d '{
    "type": "modbus_tcp",
    "ip": "192.168.1.61",
    "port": 502,
    "unit_id": 1,
    "profile": "sungrow"
  }'

Read registers (decoded, scaled):

Shell
curl -X POST http://192.168.1.100/api/modbus/read \
  -H "Content-Type: application/json" \
  -d '{"device": "INV003SIM03", "function_code": 4, "address": 13045, "count": 2,
       "decode": {"type": "u32", "endianness": "little", "scale": 0.1}}'

Get device data (JSON):

Shell
curl http://192.168.1.100/api/devices/INV003SIM03/data/json

Get device data (Raw/OBIS):

Shell
curl http://192.168.1.100/api/devices/p1m-0000a1b2c3d4e5f6/data/raw

Remove device:

Shell
curl -X DELETE http://192.168.1.100/api/devices/INV003SIM03

Remove all devices:

Shell
curl -X DELETE http://192.168.1.100/api/devices

Notes#

  • Device configs persist as /spiffs/devices/<8 hex FNV-1a of the serial>.json; backend, MQTT broker strings and log stream settings in /spiffs/system_config.json
  • Auto-reconnect every 5 s for disconnected devices
  • Modbus RTU response timeout is 1000 ms. Modbus TCP socket timeout is 5 s
  • Modbus RTU pins are fixed by the board header (RTU config sets baud, parity and unit id); P1 pins default to it
  • zap-p1 runs P1 on UART1 and Modbus RTU on UART0 together. zap-han runs RTU with either P1 or HAN on UART1. zap-eth-s3 runs P1 on UART1 and RTU on UART2 together. zap-eth has no Modbus RTU pins
  • zap-p1 sense pins are GPIO 4 and GPIO 21. Both high is logged as an RS-485 module. zap-han has no sense pins