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#
- Base URL
- Discovery
- System & Network
- Device Management
- Modbus
- Diagnostics
- Identity & Security
- OTA Updates
- Utilities
- 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.
/apiresponses carryAccess-Control-Allow-Origin: *;OPTIONSon 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=1is 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.
| What | Value |
|---|---|
| Hostname | The device id, zap-0000a1b2c3d4e5f6.local, the same as zap.deviceId in GET /api/system and name in GET /api/name |
| Alias | zap.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 records | id (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:
{
"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 real0.0.memoryKb: heap in KB;minis the lowest free heap since boot,largestthe largest free block.processesAverageis always 0.loggingis 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.firmwareVersionis the release number (version.h);zap.firmwareBuildis the running image'sgit describe(also printed asApp versionat boot).zap.networkis the object of GET /api/network.
Response (200):
{
"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):
{
"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):
{
"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. Thefactory_resetevent 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:
{
"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):
{
"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):
{
"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 }
}interfaceshas the same state as the flat fields, per interface (mac,connected,ip).wifiMacis always present.wifiStatusisconnectedordisconnected.localIP/ssid(nestedip/ssid) appear only while Wi-Fi is connected;rssi,bssidandchannel(the same names nested) only while the station is also associated with an access point.bssid(lower-case colon hex),channelandrssiare the access point the station is on, from the same reading asuplink.wifiin GET /api/gateway/status: on a mesh or behind extenders,bssidnames which access point of the SSID it is.ssidhere is the configured SSID.- A board with Ethernet adds
ethMac,ethStatus,ethConnected,ethAvailable(alwaystrue),ethLocalIP(while connected) andinterfaces.eth(mac,connected,available,ip); see theGET /api/systemexample. Without Ethernet hardware none of them appear:"eth" in interfacestells support. internetConnectedis true when a link is up and a DNS lookup ofgoogle.comsucceeds (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.
stage | Meaning | error (null while the step is still in progress) |
|---|---|---|
wifi | Joining the access point | A POST /api/wifi reason from the last disconnect (no_ap_found, wrong_password, security_unsupported, auth_failed, assoc_failed, signal_lost) |
ip | Joined, waiting for DHCP | null |
ntp | Waiting 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 |
dns | The broker hostname did not resolve | dns_failed |
broker | TCP and TLS to the broker (TCP 8883) | tcp_failed (port blocked or host unreachable), tls_failed |
auth | The broker refused the CONNECT | token_rejected (JWT refused), broker_refused |
online | MQTT 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):
{
"scanning": false,
"scanned_at_ms": 124099,
"scan_age_ms": 189,
"scan_count": 7,
"connected": "Network1",
"ssids": ["Network1", "Network2"]
}| Field | Meaning |
|---|---|
scanning | A scan is running right now |
scanned_at_ms | Uptime (ms) when the last scan finished; null before the first scan since boot |
scan_age_ms | Age of that scan in ms; null before the first scan |
scan_count | Lists stored since boot; a new value means a new list |
connected | Configured SSID while connected, otherwise empty |
ssids | Unique 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:
{
"ssid": "MyNetwork",
"psk": "mypassword"
}Response (200):
{
"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 missingssidorpsk(Missing credentials) - 500 - WiFi connect failed. The body names the reason:
{
"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:
reason | When | message |
|---|---|---|
no_ap_found | SSID 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_password | 4-way handshake failed (IDF 15, 204, 14) | Wrong WiFi password (credentials rejected) |
security_unsupported | No AP with a security mode the Zap accepts (IDF 210, 211) | WiFi network security mode not supported |
auth_failed | The AP rejected authentication; with WPA3-SAE usually the password (IDF 202, 2) | WiFi authentication failed (check the password) |
assoc_failed | The AP refused the association, or another IDF reason | Access point refused to connect |
signal_lost | Beacons stopped (IDF 200) | WiFi signal too weak |
no_ip | Joined the AP, but DHCP gave no address | Joined the WiFi network but got no IP address |
timeout | Nothing came back before the wait ended (also IDF 3, 8) | WiFi connection timed out |
internal_error | The Zap could not start the attempt | WiFi 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):
{
"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):
{
"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):
| Status | status | reason | When |
|---|---|---|---|
| 409 | busy | scan_in_progress | Another scan was still running after the 20 s wait. Retry in a few seconds |
| 409 | busy | connecting | A connect attempt was still in progress after the 20 s wait |
| 503 | error | not_initialized | WiFi is not up yet |
| 504 | error | timeout | The WiFi driver timed out |
| 500 | error | scan_failed | The WiFi driver could not start or read the scan |
{"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):
{
"status": "success",
"message": "BLE stopping..."
}GET/api/system/backend#
Get the current backend environment.
Response (200):
{
"backend": "testnet"
}Values: "devnet", "testnet", "mainnet".
GET/api/system/mqtt#
Get the MQTT broker strings.
Response (200):
{
"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:
{
"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):
{
"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):
{
"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:
{
"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:
{
"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 }
]
}
}
}| Key | Parameters as listed (* = required: true, default in brackets) |
|---|---|
modbus_tcp | ip*, port* (502), unit_id* (1), profile* |
modbus_rtu | baud_rate* (9600), unit_id* (1), profile*, parity (integer, 0; 0=None, 1=Odd, 2=Even), data_bits (8), stop_bits (1) |
p1_uart | manufacturer, baud_rate* (115200), data_bits* (8), parity (string, none), stop_bits (1) |
mqtt | broker_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 statelast_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'sread_time_ms); omitted when nothing was measured, e.g. a device whose data is pushed over MQTTlast_error: string – the last failure the shared connect and harvest paths saw:connect_failed(a connect attempt failed) orconnection_lost(the device dropped during a harvest). Kept after a later success; omitted if none since the device was loadedlast_error_ts: unix ms timestamp oflast_error(sent with it). A failure before the clock was set (early boot) carries milliseconds since boot, like any timestamp taken thenname: string – display name of the device's profile (Solis,SDM630 Meter,P1 Meter, as inGET /api/devices/supported); omitted when the profile is unknownframing: string – serial framing a P1/HAN meter was detected on, e.g.115200 8N1; omitted until a port is detecteddisconnects: 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 inGET /api/devices/{sn}/statusconnected_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 connectedpath: string – the path to the configuration file in SPIFFS (e.g.spiffs/devices/6178e775.json)host_mac: string –modbus_tcpandmqttonly: 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 addressders(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: solarinstalled_power_W_dc, batteryrated_power_W_dcandcapacity_Wh_dc, inverterrated_power_W_ac, meterlocation(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):
{
"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:
{
"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,0off, at most 10080), the Zap scans its subnet for the device's port, keeps hosts whose ARP MAC is the storedhost_mac, and moves the device to the one that answers with its serial number (eventdevice_host_ip_changed). Stored only when given.
Modbus RTU (RS-485):
{
"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:
{
"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 whichbaud_rate: Integer > 0 (e.g., 9600, 115200)data_bits: 5, 6, 7, 8parity: "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 400Invalid 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):
{
"type": "mqtt",
"profile": "ferroamp",
"broker_host": "192.168.1.70",
"broker_port": 1883,
"username": "extapi",
"password": "your_password"
}MQTT (Ferroamp V2X Charger):
{
"type": "mqtt",
"profile": "ferroamp_v2x",
"broker_host": "192.168.1.70",
"broker_port": 1883,
"username": "dc2",
"password": "your_password"
}MQTT (Ambibox EV Charger):
{
"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,nullor"") it iszap-and 16 hex digits of a hash of the Zap's id, the profile,broker_host(lower case) andbroker_port, for examplezap-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, andbroker_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:
{ "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):
{
"type": "mqtt",
"profile": "ambibox",
"broker_host": "charger.local",
"use_tls": true,
"tls_ca": "-----BEGIN CERTIFICATE-----\nMIIDBDCCAeyg...\n-----END CERTIFICATE-----\n"
}use_tls:trueorfalse(defaultfalse); any other value is a 400. With TLS and nobroker_portthe port is 8883.- The Zap connects to the resolved IP of
broker_host(mDNS.localnames too), but SNI and the certificate check usebroker_hostas 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 withuse_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 withoutuse_tls: true;nullis the same as notls_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 byGET /api/devicesandGET /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 failedorconnect failed.
A rejected MQTT broker setting is a 400 that names the field, for example:
{ "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:
{
"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):
{
"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:trueif the window of the latest reading came from the device,falseif 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 invaliddersarray (seePOST /api/devices/{sn}/ders); nothing is added - 409 - A second P1/HAN device (
Smart meter UART device already exists);Device already exists or conflicts, withsn(same serial number, same TCPip:port:unit_id, or same RTUunit_idon 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_blockandrequired_blockin 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):
{
"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):
{
"message": "Device updated",
"sn": "INV003SIM03"
}Note: The
dersarray is optional. DER types, enabled flags and ratings that the request does not carry are preserved unchanged, so a PUT withoutdersupdates only the connection parameters.
Note: An MQTT config is validated as in
POST /api/devices. To turn TLS off, send"use_tls": falsewithouttls_ca(or with"tls_ca": null): atls_cawithout TLS is a 400.
Errors:
- 400 - Invalid config (an MQTT broker setting names the field, see
POST /api/devices) or an invaliddersarray (seePOST /api/devices/{sn}/ders) - 404 - Device not found (not loaded)
- 409 - Serial number mismatch: the serial the new config yields is not
{sn}(expected_snandactual_snin 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):
{
"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 ongoingcause:connection_lost(the device dropped during a harvest) orconnect_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):
{
"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):
{ "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):
{ "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):
{
"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:
{
"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_uartorhan_uartdata: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:
{
"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:
{
"device_type": "p1_uart",
"sn": "p1m-0000a1b2c3d4e5f6",
"last_failed_hex": "",
"message": "No failed frames recorded yet"
}Response (200) - Non-P1 device:
{
"sn": "INV003SIM03",
"message": "Debug info not available for this device type"
}Fields:
device_type: connection type,p1_uartorhan_uartlast_failed_ts: milliseconds since boot when the frame failed to decodelast_failed_type: frame type (0=ASCII, 1=HDLC, 2=MBUS)last_failed_size: bytes kept, at most 1024last_failed_hex: those bytes asXXhexlast_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:
{
"ders": [
{ "type": "solar", "enabled": true },
{ "type": "battery", "enabled": false }
]
}Response (200):
{ "message": "Types updated", "sn": "INV003SIM03" }"message": "No changes" when no flag changed.
Errors:
- 400 -
dersmissing 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/typesor/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):
{
"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 Type | Field | Unit | Description |
|---|---|---|---|
| solar | enabled | - | Enable/disable publishing |
| solar | installed_power_W_dc | W | Installed panel power (DC); 0 = not set |
| battery | enabled | - | Enable/disable publishing |
| battery | rated_power_W_dc | W | Battery rated power (DC); 0 = not set |
| battery | capacity_Wh_dc | Wh | Battery capacity; 0 = not set |
| battery | min_soc_fract | fract | Lower SoC limit (0.0–1.0), read-only: device-read, else 0.05 |
| battery | max_soc_fract | fract | Upper SoC limit (0.0–1.0), read-only: device-read, else 1.00 |
| battery | adjustable | - | true if the SoC window was read from the device |
| meter | enabled | - | Enable/disable publishing |
| meter | location | - | Where the meter measures: grid, generation or load; left out when not set. A P1 or HAN meter defaults to grid |
| inverter | enabled | - | Enable/disable publishing (default true) |
| inverter | rated_power_W_ac | W | The inverter's rated AC power; 0 = not set. Caps the battery's discharge limit: -min(battery rated, inverter rated - PV) |
| ev_charger_port | enabled | - | 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"}:
message | When |
|---|---|
'ders' is not an array / 'ders' is not a valid array | ders is not a JSON array |
DER entry is not an object / DER entry has no type | An entry is not an object, or has no type |
Unknown DER type / Duplicate DER type | type is not a v2 DER type, or is listed twice |
Invalid DER field | enabled 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:
{
"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):
{ "message": "DERs updated", "sn": "INV003SIM03" }"message": "No changes" when nothing changed; the config is then not saved.
Errors:
- 400 -
Missing 'ders' array, or an invaliddersarray (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:
| Target | Fields | Connection |
|---|---|---|
| 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-255 | Connected 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-247 | The 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):
{ "error": "exception", "message": "The device answered with a Modbus exception", "exception_code": 2 }| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | Invalid request; message says which field. Also a device that is not a Modbus device |
| 404 | not_found | No configured device with that serial number |
| 502 | exception | The device answered with a Modbus exception; exception_code is its code (1 illegal function, 2 illegal data address, 3 illegal data value, ...) |
| 502 | not_connected | The configured device is not connected, or the request could not be sent |
| 502 | connect_failed | The TCP connection or the RS-485 port could not be opened |
| 502 | protocol | An answer that is not a valid Modbus reply (CRC, length, ids) |
| 503 | busy | The configured device stayed locked for 3 s |
| 504 | timeout | No 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.
| Field | Required | Description |
|---|---|---|
| target | Yes | device, or tcp / rtu with unit_id (see above) |
function_code | Yes | 3 (holding) or 4 (input) |
address | Yes | First register, 0-65535 |
count | Yes | Registers, 1-125 |
decode | No | {"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)
{
"device": "SOLIS0000000001",
"function_code": 4,
"address": 33149,
"count": 2,
"decode": { "type": "i32", "endianness": "big" }
}Response (200):
{
"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)
{
"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):
{
"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 bydevice; one with no known hardware id isnull, last.nullwhile the harvest worker is stuck (no device's state is known).state:ok,staleorunreachable;since_atis when it began,last_ok_atthe last good read (nullwhen 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, ...);nullwhen ok.storageisnull, anduplink.buffering/backlogarefalse/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:wifiorethernet;nullwhen 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 }(bssidlower-case colon hex;ssidas the access point reports it, a byte that is not UTF-8 written as?), also when the traffic goes over Ethernet;nullwhen associated with none. Read when the snapshot is written, asGET /api/networkreads it.controlis alwaysl1.
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:
{
"host": "192.168.1.60",
"port": 502,
"timeout": 2000
}timeout (ms) is optional, default 2000. The request blocks until the connect ends.
Response (200):
{
"host": "192.168.1.60",
"port": 502,
"open": true
}Errors:
- 400 -
Missing required field: hostorMissing 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:
{
"ports": [502, 80],
"timeout": 500,
"batch_size": 12
}| Field | Required | Default | Description |
|---|---|---|---|
ports | No | [502] | TCP ports to check: the first 8 in 1-65535 are used; none valid gives [502] |
timeout | No | 500 | Time budget per batch of probes in ms, waited in 100 ms rounds, clamped to 50-5000 |
batch_size | No | 12 | Concurrent connections, clamped to 1-12. The value is cut to 8 bits first, so 256 becomes 1 |
Response (200):
{
"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):
{
"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:
{
"message": "optional-message-content",
"timestamp": "optional-timestamp"
}Response (200):
{
"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):
{
"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):
{
"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):
{
"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-IDFesp_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 asreset reason: <name> (<n>).resetReasonName:resetReasonas a word:power_on,external_pin,software,panic,interrupt_watchdog,task_watchdog,watchdog,deep_sleep,brownout,sdio,usb,jtag,efuse,power_glitch,cpu_lockup, orunknown.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:
{"any":"content"}Response (200):
{
"echo": "{\"any\":\"content\"}"
}Reference#
Status Codes#
| Code | Meaning |
|---|---|
| 200 | OK — request succeeded |
| 201 | Created — resource was created |
| 202 | Accepted — async operation queued |
| 204 | No Content — no data available yet (the body still has a message) |
| 400 | Bad Request — invalid JSON, missing fields, or bad parameter values |
| 404 | Not Found — no such resource, or no route for this path and method ({"status":"error","message":"Not found"}) |
| 405 | Method Not Allowed — GET /api with another method; POST /api/devices/{sn} without /types or /ders |
| 409 | Conflict — resource already exists or operation conflicts |
| 500 | Internal Server Error — operation failed |
| 502 | Bad Gateway — Modbus connection or reply failure |
| 503 | Service Unavailable — device busy, connection failed, or insufficient memory |
| 504 | Gateway Timeout — WiFi scan driver timeout, or no Modbus answer in time |
Examples#
Add TCP device:
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):
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:
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):
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):
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):
curl http://192.168.1.100/api/devices/INV003SIM03/data/jsonGet device data (Raw/OBIS):
curl http://192.168.1.100/api/devices/p1m-0000a1b2c3d4e5f6/data/rawRemove device:
curl -X DELETE http://192.168.1.100/api/devices/INV003SIM03Remove all devices:
curl -X DELETE http://192.168.1.100/api/devicesNotes#
- 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