LAN Integration (Local API) – Integration Guide

LAN Integration (Local API)

Talk directly to the IOmeter Bridge on the customer's local network — no cloud dependency, no account linking.

LAN Integration (Local API)

IOmeter's Bridge serves meter readings directly on the local network — no cloud dependency, no account linking. There are three transports; pick based on your deployment constraints.

Transport Mode Discovery Use when
HTTP polling Pull mDNS Simple integrations; any HTTP client; a low update rate is acceptable
HTTP SSE Pull (streaming) mDNS Low latency; one persistent connection per consumer; device must be reachable
UDP multicast Push Provisioning config Multiple consumers on the same LAN; fire-and-forget; no per-consumer connection needed

Capacity note: the Bridge serves HTTP with a pool of only 4 worker tasks, shared across all endpoints and both polling and SSE. If you expect more than 2–3 concurrent consumers, prefer UDP multicast over multiple HTTP/SSE connections — see Connection Management.

HTTP polling and SSE require your client to know the device's IP address (via mDNS — see Discovery). The device accepts incoming connections.

UDP multicast requires the device to be provisioned (via IOmeter Cloud or local provisioning) with a multicast group address and port. Your client joins the group and receives datagrams; it never connects to the device directly.

Where to start

  1. Discovery — find the device on the network
  2. HTTP: Polling & SSE — the three HTTP endpoints and how to consume them
  3. Connection Management — the worker-pool limit and how to design around it
  4. UDP Multicast — the push-based alternative for multiple consumers
  5. Data Models — full schemas for every message/response type
  6. cURL Examples — copy-pasteable requests
  7. Integration Checklist — a checklist for verifying a client implementation

Discovery

The Local API uses HTTP (not HTTPS) on port 80. The device's address is found via mDNS.

  1. Resolve the service type _iometer._tcp.local. via mDNS.
  2. Extract the IP address and port from the service record (port is always 80 in current firmware).
  3. Construct the base URL: http://{ip}/v1

The mDNS hostname follows the pattern IOmeter-{XXYYZZ}.local, where XXYYZZ are the last three octets of the WiFi MAC address in hex.

Re-resolving

If repeated connection attempts fail, re-run mDNS discovery — the device's IP may have changed after a WiFi reconnect. Don't cache the resolved IP indefinitely.

Next: HTTP: Polling & SSE.

HTTP: Polling & SSE

Choosing an endpoint

Endpoint Format Use when
GET /v1/reading Full OBIS registers You need raw meter data or meter-specific OBIS codes
GET /v1/json Flat power/energy fields Integrating with battery inverters or home energy management systems expecting the EcoTracker local API format
GET /v1/status Device health Monitoring Bridge/Core connectivity, firmware versions, battery level

Responses are JSON. Fields that aren't available are omitted, not null — e.g. if the meter provides no energy-production measurement, that field is absent from the response. Use null-safe / key-absent-safe access throughout.

Polling

  • Send a GET request and parse the JSON body.
  • Align your polling interval with the device's configured reading frequency — polling faster than the update rate just wastes requests, since the response won't change between meter updates.
  • Suggested interval for /v1/status: 10–30 s (it changes infrequently).
  • Set a connect timeout of at least 5 s and a read timeout of at least 10 s.
  • On failure, use exponential backoff: start at 1 s, cap at 60 s.

SSE (Server-Sent Events)

Supported on /v1/reading and /v1/status only — /v1/json is poll-only.

  • Set Accept: text/event-stream on the request.
  • The device holds the connection open and sends a new event whenever the value changes.
  • Each event has a named type (readingEvent or statusEvent) and a JSON payload in the data: field — same schema as the equivalent polling response.
  • The device sends a bare newline keep-alive when there's no new data, so clients can detect disconnection.
  • On connection drop, reconnect after a short delay (1–2 s). Last-Event-ID resume is not supported.
  • Use a streaming-capable HTTP client; don't buffer the full response before parsing.
  • An open SSE connection holds a worker task for as long as it stays open (see Connection Management) — don't open more than you need, and close them when you no longer need live updates.

GET /v1/reading

{
   "__typename": "iometer.reading.v1",
   "installationId": "22231002-eee7-41b3-ac68-6d90f4be5db0",
   "meter": {
      "number": "1ESY1161338362",
      "reading": {
         "time": "2026-03-13T14:20:24Z",
         "registers": [
            { "obis": "01-00:01.08.00*ff", "value": 6279091, "unit": "Wh" },
            { "obis": "01-00:10.07.00*ff", "value": 179.29, "unit": "W" },
            { "obis": "01-00:24.07.00*ff", "value": 179.29, "unit": "W" }
         ]
      }
   }
}
Field Type Description
__typename string Always iometer.reading.v1
installationId string Unique identifier of the IOmeter device setup
meter.number string Meter serial number
meter.reading.time string ISO 8601 timestamp of the reading
meter.reading.registers array Registers reported by the meter, identified by OBIS code — see the OBIS Codes reference

GET /v1/json — Simple Reading (EcoTracker format)

A compact, flat JSON format for home energy management systems and inverters that expect simple numeric fields rather than raw OBIS registers.

3-phase meter, all registers present:

{
   "power": 450.0,
   "powerAvg": 423,
   "agePower": 1050,
   "powerPhase1": 180.0,
   "powerPhase2": 145.0,
   "powerPhase3": 125.0,
   "energyCounterIn": 6279091.0,
   "energyCounterInT1": 4100000.0,
   "energyCounterInT2": 2179091.0,
   "energyCounterOut": 312500.0
}

Single-phase meter, net power + total energy only:

{
   "power": 179.29,
   "powerAvg": 171,
   "agePower": 850,
   "energyCounterIn": 6279091.0
}

Single-phase meter reporting only L1 (no net-power OBIS):

{
   "power": 179.29,
   "powerAvg": 171,
   "agePower": 850,
   "powerPhase1": 179.29,
   "powerPhase2": 0.0,
   "powerPhase3": 0.0,
   "energyCounterIn": 6279091.0
}
Field Type Present when Description
power number (W) Meter provides power data Current net power. Positive = consumption, negative = feed-in
powerAvg number (W) Meter provides power data Rolling average of instantaneous power (up to ~60 s window), rounded to integer
agePower number (ms) Meter provides power data Milliseconds since the last power reading was received from the meter
powerPhase1/2/3 number (W) Meter provides L1 OBIS (24.07.00) Per-phase power; L2/L3 are 0 when not reported by the meter
energyCounterIn number (Wh) OBIS 01.08.00 Cumulative energy consumption
energyCounterInT1 number (Wh) OBIS 01.08.01 Cumulative consumption, tariff 1
energyCounterInT2 number (Wh) OBIS 01.08.02 Cumulative consumption, tariff 2
energyCounterOut number (Wh) OBIS 02.08.00 Cumulative energy production (feed-in)

OBIS → field mapping:

OBIS Code JSON field
01-00:10.07.00*ff power
01-00:24.07.00*ff powerPhase1
01-00:38.07.00*ff powerPhase2
01-00:4C.07.00*ff powerPhase3
01-00:01.08.00*ff energyCounterIn
01-00:01.08.01*ff energyCounterInT1
01-00:01.08.02*ff energyCounterInT2
01-00:02.08.00*ff energyCounterOut

Phase power behavior:

Situation Phase fields present? Values
No L1 OBIS (24.07.00) from meter No Fields omitted entirely
L1 present; L2/L3 absent Yes powerPhase1 = L1 value; powerPhase2/3 = 0
L1, L2, L3 all present Yes Each field = meter value

If the meter provides no net-power OBIS (10.07.00) but does provide L1 power (24.07.00), L1 is promoted to power and phase fields are included — this covers single-phase meters that report current as L1 rather than total power. There is no power / 3 fallback.

powerAvg is a sliding window average of up to 60 consecutive power samples; the window resets if the gap between two readings exceeds 30 s. On the very first reading after device start, powerAvg equals the instantaneous power value.

GET /v1/status

{
   "__typename": "iometer.status.v1",
   "installationId": "22231002-eee7-41b3-ac68-6d90f4be5db0",
   "meter": { "number": "1ESY1161338362" },
   "device": {
      "bridge": { "rssi": -83, "version": "app:build-7, idf:v5.5.3" },
      "id": "f0f2d298-aa67-4b22-9d3d-f099eca3066f",
      "core": {
         "connectionStatus": "connected",
         "rssi": -75,
         "version": "build-76.8",
         "powerStatus": "battery",
         "batteryLevel": 100,
         "attachmentStatus": "attached",
         "pinStatus": "entered"
      }
   }
}
Field Type Description
__typename string Always iometer.status.v1
installationId string Unique identifier of the IOmeter device setup
meter.number string Meter serial number
device.bridge.rssi integer? WiFi signal strength in dBm
device.bridge.version string? Bridge firmware version
device.id string Unique device identifier
device.core.connectionStatus string 868 MHz RF link status: connected | disconnected
device.core.rssi integer? 868 MHz RF link signal strength in dBm
device.core.version string? Core firmware version
device.core.powerStatus string? battery | wired
device.core.batteryLevel integer? 0–100, only present when on battery
device.core.attachmentStatus string? attached | detached (to the meter's optical port)
device.core.pinStatus string? Utility meter PIN status: entered | pending | missing

Fields marked ? are omitted from the response when not available — always use null-safe access. See Data Models for the same schemas expressed as type definitions, and cURL Examples for copy-pasteable requests against all three endpoints.

Connection Management

The Bridge serves HTTP with a fixed pool of 4 worker tasks, shared across /v1/reading, /v1/status, and /v1/json, and across both polling and SSE. There is no per-endpoint quota — it's one pool for the whole device.

  • Concurrency budget: at most 4 requests/streams in flight at once, in any mix of endpoints and transport (poll vs. SSE).
  • An open SSE connection occupies one worker for its entire lifetime. A single long-running SSE subscription counts against the same pool as ordinary polling requests — so 4 concurrent SSE streams (or fewer SSE streams plus some polling requests) will fully saturate the device. A client holding 2 SSE connections open already uses half the device's total capacity.
  • The HTTP server accepts at most 5 concurrent TCP connections (workers + 1). The extra slot lets a new connection be accepted and answered with 503 even while all 4 workers are busy, rather than the connection being refused outright.
  • Response payloads are also guarded by a single shared lock across all endpoints; under heavy contention a request may receive 408 instead of 503.

Handling capacity errors

Response Meaning What to do
503 Busy No worker was free when the request arrived. Normal under load, not an error condition. Retry after 1–2 s.
408 Request Timeout The response-payload lock was briefly contended. Handle identically to 503. Retry after 1–2 s.
404 on /v1/reading or /v1/status Device is running but no reading/status is available yet. Retry at the normal polling interval.

Designing multi-consumer deployments

  • Keep simultaneous connections to a device low — 2–3 concurrent clients at most leaves headroom.
  • 4+ concurrent SSE streams will saturate the device and cause other clients — including polling clients — to start receiving 503.
  • For fan-out to multiple LAN consumers, prefer UDP multicast over opening several HTTP/SSE connections.
  • If repeated connection attempts fail (not just capacity errors), re-run mDNS discovery — the device IP may have changed after a WiFi reconnect.

UDP Multicast

In addition to the HTTP pull API, IOmeter can push meter readings and status updates to a UDP multicast group. Use this when multiple consumers on the same LAN need the data simultaneously without polling, or when a lightweight, connectionless receive loop is preferred.

This is a pure push model: the device sends a datagram each time a new reading or status update is available. There is no request/response cycle, no delivery guarantee, and no ordering guarantee.

Configuration

The multicast group address, port, TTL, and reading frequency are configured via IOmeter provisioning (IOmeter Cloud or the local provisioning interface) — a client cannot enable multicast by itself, it only joins the already-configured group and listens.

Parameter Description
ip_addr IPv4 multicast group address (e.g. 239.255.0.1)
port UDP destination port
ttl IP multicast TTL — how many router hops the datagram may traverse (1 = link-local only)
frequency Which reading frequency triggers a multicast send (matches the device's configured update rate)

Message types

Both message types use the same JSON schema as their HTTP equivalents — see Data Models.

__typename Equivalent HTTP endpoint Sent
iometer.reading.v1 GET /v1/reading On each meter reading at the configured frequency
iometer.status.v1 GET /v1/status On every device status update

The simple reading format (/v1/json) is not sent via multicast — derive it from the OBIS registers in iometer.reading.v1 if you need that flat shape, or use HTTP.

Constraints

  • Each message is a single UDP datagram, max 1460 bytes — there's no framing or length prefix, one datagram = one complete JSON message.
  • UDP provides no delivery guarantee — datagrams may be lost or reordered, especially over WiFi. Design consumers to tolerate gaps.
  • There is no heartbeat. A silent socket doesn't mean the device is down — combine multicast receive with periodic HTTP polling of /v1/status if liveness matters.
  • The device must have WiFi connectivity; multicast is suspended while disconnected.

Receiving messages

  1. Create a UDP socket bound to the multicast port on 0.0.0.0 (all interfaces).
  2. Set SO_REUSEADDR so multiple processes on the host can bind the same port.
  3. Join the multicast group with IP_ADD_MEMBERSHIP.
  4. Read datagrams in a loop; each is a complete, self-contained JSON message.
  5. Decode as UTF-8 and parse as JSON.
  6. Dispatch on __typename.
import socket, struct, json

MCAST_GRP  = "239.255.0.1"  # from device provisioning
MCAST_PORT = 5007           # from device provisioning

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", MCAST_PORT))
mreq = struct.pack("4sL", socket.inet_aton(MCAST_GRP), socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)

while True:
    data, addr = sock.recvfrom(2048)
    try:
        msg = json.loads(data.decode("utf-8"))
    except ValueError:
        continue  # discard malformed datagrams, keep the loop alive
    match msg.get("__typename"):
        case "iometer.reading.v1":
            handle_reading(msg)
        case "iometer.status.v1":
            handle_status(msg)

Inspecting raw datagrams from the command line with socat:

socat UDP4-RECVFROM:5007,ip-add-membership=239.255.0.1:0.0.0.0,fork -

Data Models

Compact type schemas for every Local API message. These are the same payloads documented with prose and examples in HTTP: Polling & SSE and UDP Multicast — use this page as a quick reference when writing a parser, or a starting point for generating types in your own language.

iometer.reading.v1 and iometer.status.v1 are also the payloads delivered by Push Integration; everything on this page applies there too.

iometer.reading.v1

Returned by GET /v1/reading, the readingEvent SSE event, and multicast reading messages.

{
  "__typename": "iometer.reading.v1",   // always present
  "installationId": string,             // optional
  "externalId": string,                 // optional
  "meter": {
    "number": string,                   // optional — absent before first reading
    "reading": {
      "time": string,                   // ISO 8601 UTC
      "registers": [
        {
          "obis": string,               // e.g. "01-00:01.08.00*ff"
          "value": number,
          "unit": string                // e.g. "Wh", "W"
        }
      ]
    }
  }
}

Fields at any level may be absent if the device hasn't yet received data from the Core. Always use null-safe access.

iometer.status.v1

Returned by GET /v1/status, the statusEvent SSE event, and multicast status messages.

{
  "__typename": "iometer.status.v1",    // always present
  "installationId": string,             // optional
  "externalId": string,                 // optional
  "meter": {
    "number": string                    // optional
  },
  "device": {
    "bridge": {
      "rssi": integer,                  // optional, dBm
      "version": string                 // optional
    },
    "id": string,                       // optional
    "core": {
      "connectionStatus": string,       // "connected" | "disconnected"
      "rssi": integer,                  // optional, dBm
      "version": string,                // optional
      "powerStatus": string,            // optional: "battery" | "wired"
      "batteryLevel": integer,          // optional, 0-100, only with "battery"
      "attachmentStatus": string,       // optional: "attached" | "detached"
      "pinStatus": string               // optional: "entered" | "pending" | "missing"
    }
  }
}

/v1/json (Simple Reading — HTTP poll only, not sent via multicast)

{
  "power":             number,   // W, net (positive = consumption, negative = feed-in) — present when meter provides power data
  "powerAvg":          number,   // W, sliding window average (up to ~60 s), rounded — present when meter provides power data
  "agePower":          number,   // ms since last power reading was received — present when meter provides power data
  "powerPhase1":       number,   // W — present only when meter provides L1 OBIS (24.07.00)
  "powerPhase2":       number,   // W — present only when meter provides L1 OBIS (0 when L2 not reported)
  "powerPhase3":       number,   // W — present only when meter provides L1 OBIS (0 when L3 not reported)
  "energyCounterIn":   number,   // Wh, cumulative — present when meter provides OBIS 01.08.00
  "energyCounterInT1": number,   // Wh, tariff 1 — present when meter provides OBIS 01.08.01
  "energyCounterInT2": number,   // Wh, tariff 2 — present when meter provides OBIS 01.08.02
  "energyCounterOut":  number    // Wh, cumulative feed-in — present when meter provides OBIS 02.08.00
}

All fields are optional — only fields for which the meter supplies the underlying OBIS register are included; there is no null placeholder for missing ones. See HTTP: Polling & SSE for the OBIS-to-field mapping and phase-power behavior.

cURL Examples

Polling

# Get current reading (full OBIS format)
curl --url "http://192.168.1.100/v1/reading" \
  --header "Accept: application/json"

# Get current reading (EcoTracker simple format)
curl --url "http://192.168.1.100/v1/json" \
  --header "Accept: application/json"

# Get device status
curl --url "http://192.168.1.100/v1/status" \
  --header "Accept: application/json"

SSE

# Subscribe to reading events (full OBIS format)
curl -N -H "Accept: text/event-stream" http://iometer-fc0804.local/v1/reading

SSE is only supported on /v1/reading and /v1/status. The simple reading endpoint /v1/json is poll-only.

Example output:

event: readingEvent
data: {"__typename":"iometer.reading.v1","installationId":"22231002-eee7-41b3-ac68-6d90f4be5db0","meter":{"number":"1ESY1161338362","reading":{"time":"2026-03-13T14:42:17Z","registers":[{"obis":"01-00:01.08.00*ff","value":6279149,"unit":"Wh"},{"obis":"01-00:10.07.00*ff","value":157.01,"unit":"W"},{"obis":"01-00:24.07.00*ff","value":157.01,"unit":"W"}]}}}

event: readingEvent
data: {"__typename":"iometer.reading.v1","installationId":"22231002-eee7-41b3-ac68-6d90f4be5db0","meter":{"number":"1ESY1161338362","reading":{"time":"2026-03-13T14:42:18Z","registers":[{"obis":"01-00:01.08.00*ff","value":6279150,"unit":"Wh"},{"obis":"01-00:10.07.00*ff","value":156.27,"unit":"W"},{"obis":"01-00:24.07.00*ff","value":156.27,"unit":"W"}]}}}

Testing Checklist

Use this list to verify a Local API client implementation.

HTTP

☐ mDNS discovery resolves the device correctly

☐ Polling returns valid JSON for all three endpoints

☐ Optional fields in /v1/reading, /v1/status, and /v1/json handled with null-safe access

/v1/json — fields absent when meter does not supply the corresponding OBIS register

/v1/jsonagePower present and positive when power data is available

☐ First-reading powerAvg equals power (no previous sample available)

☐ Phase fields absent when meter provides no L1 OBIS; present (with L2/L3 = 0) for single-phase L1-only meters

☐ SSE events parsed correctly on /v1/reading and /v1/status; data: field extracted and decoded as JSON

☐ SSE reconnection after connection drop

/v1/json does not accept SSE (Accept: text/event-stream ignored — poll only)

503 Busy handled with retry

408 Request Timeout handled with retry (same as 503)

404 on reading/status handled gracefully (data not yet available)

☐ Exponential backoff on repeated failures

☐ Opening 5+ concurrent connections (poll and/or SSE, any endpoint mix) triggers 503 on the excess connections — confirms the 4-worker pool is shared across endpoints, not per-endpoint

☐ A long-lived SSE connection is accounted for against the same capacity as polling requests (verify polling from a second client still succeeds while ≤3 other slots are in use, and starts failing with 503 once all 4 are occupied)

UDP Multicast

☐ Client joins the correct multicast group and port

iometer.reading.v1 messages received and parsed

iometer.status.v1 messages received and parsed

☐ Unknown __typename values discarded without error

☐ Malformed JSON discarded without crashing the receive loop

☐ Client continues operating after dropped packets (no state corruption)

☐ Behavior when multicast is disabled on the device (socket silently receives nothing)