One of the best built-in features of Home Assistant is its Energy Dashboard. Feed it cumulative electricity, water, and gas counters, and it gives you long-term hourly and daily consumption curves, cost breakdowns, and baselines against individual smart plugs and air-conditioning circuits.
In Singapore, SP Group (SP Services / SP Digital) has rolled out Advanced Metering Infrastructure (AMI) smart electricity meters across residential households. Instead of waiting for a manual meter reader once every two months, AMI meters record half-hourly electricity consumption (48 intervals per day) alongside monthly water (m³) and piped town gas (kWh) readings.
In a typical Singapore condominium or HDB flat, however, your utility meters sit inside a common riser closet in the lift lobby—meaning clamping a local CT sensor or ESPHome optical pulse reader onto the meter isn't practical. While the official SP app displays this data nicely on your phone, there is no built-in way to feed your household's own utility readings into Home Assistant for local energy automation and historical dashboards.
Fresh off building iplusliving2mqtt for my condo intercom, I wanted the exact same setup for my household utilities: a tiny, polite, self-contained Rust daemon running in Kubernetes that authenticates with my SP account and exposes my home's meter readings to Home Assistant via MQTT Auto-Discovery.
That project is sp2mqtt.
Understanding the Client Protocol: Three Design Challenges
Inspecting how the mobile client communicates with SP Digital's backend (identity.spdigital.sg and b2c.api.spdigital.sg) highlighted three interesting engineering problems to solve for a clean, unattended Kubernetes integration.
1. Standard Auth0 Android SDK Telemetry & Request Metadata
SP Digital authenticates users against https://identity.spdigital.sg using Auth0's Resource Owner Password Realm flow (http://auth0.com/oauth/grant-type/password-realm) with realm Username-Password-Authentication and audience https://profile.up.spdigital.sg/.
Like most modern Auth0 deployments, the identity provider expects requests to include standard Auth0 mobile SDK telemetry and client headers rather than bare HTTP requests with a generic library User-Agent. When a request omits the expected SDK context, Auth0 flags it for additional verification:
{
"error": "requires_verification",
"error_description": "Suspicious request requires verification"
}
Looking at how the client's HTTP layer is structured, outgoing requests include standard metadata headers across three areas:
- Application User-Agent: Identifies the client version (
Infinity/<versionName> (Android)). - Auth0 SDK Telemetry: Includes the standard
Auth0-Clientheader (Base64URL-encoded JSON describing theAuth0.AndroidSDK version and target API level) along withAccept-Language: en-US. - Installation & Distributed Tracing Headers: Attaches a persistent per-install UUIDv4 (
X-Installation-ID) and standard Zipkin B3 tracing headers (X-B3-TraceIdandX-B3-SpanId) for request correlation.
Including the standard client headers and OAuth2 scopes (openid email profile offline_access enroll read:authenticators user_metadata me me:uportal me:eva me:rbac) allows the OAuth2 handshake to proceed smoothly into the standard Multi-Factor Authentication flow.
2. Handling SMS OTP Multi-Factor Authentication in Kubernetes
Security on utility accounts matters, and SP Digital enforces Multi-Factor Authentication (MFA). Once password authentication succeeds, Auth0 responds with HTTP 403 and {"error": "mfa_required", "mfa_token": "..."}. Completing the login follows the standard Auth0 Out-of-Band (OOB) sequence:
- Querying
GET /mfa/authenticatorswithBearer <mfa_token>to list the account's active authenticators (such assms|dev_...). - Calling
POST /mfa/challenge(challenge_type: "oob") to request a 6-digit SMS OTP and receive anoob_code. - Submitting the 6-digit
binding_codetoPOST /oauth/tokenusing grant typehttp://auth0.com/oauth/grant-type/mfa-oob.
Because the initial scope includes offline_access, completing MFA issues a long-lived refresh_token. Subsequent token refreshes (grant_type: "refresh_token") mint fresh 1-hour access_token and id_token JWTs automatically without sending repeat SMS messages.
Still, I wanted the initial onboarding—and any future re-authentication if a session ever expires—to feel native in a headless Kubernetes deployment without needing to kubectl exec into a running pod.
To achieve that, sp2mqtt exposes the MFA workflow directly inside Home Assistant over MQTT:
- When
sp2mqttstarts withSP_EMAILandSP_PASSWORD(and no cachedrefresh_tokenon its persistent volume), it publishes an SP Services Account device via MQTT Auto-Discovery withsensor.sp_services_auth_statusset toMFA Required. - Pressing the
1. Send SP SMS OTPbutton in Home Assistant triggers the Auth0 OOB challenge and updates the status sensor toOTP Sent (XXXXXXX8632). - Typing the 6-digit code into the
2. Submit SP 6-Digit SMS OTPtext box in Home Assistant completes themfa-oobtoken exchange, atomically saves the session to/data/state.json, and starts the telemetry poller.
For declarative GitOps setups, sp2mqtt also supports two non-interactive authentication paths:
- Pre-provisioned
SP_REFRESH_TOKEN: Seeding an initialrefresh_tokenandinstallation_idvia a KubernetesSecretso the daemon can mint access tokens immediately on first boot. - RFC 6238 TOTP (
SP_TOTP_SECRET): If an OTP authenticator is enrolled on the account,sp2mqttcan compute the 6-digit TOTP code in memory during login without waiting for an SMS challenge.
3. Reconciling Monthly Meter Reads with Half-Hourly AMI Intervals
Authenticated requests to SP Digital's B2C API (https://b2c.api.spdigital.sg) include both the OAuth2 Authorization: Bearer <access_token> header and the X-id-token: <id_token> header.
One helpful detail when working with the API is how accounts and premises are indexed: GET /jarvis/v3/me returns both a 12-digit billing account_number (008938562736) and a 10-digit premise_id (2100102993). All /jarvis/* utility endpoints are keyed by the 10-digit premise_id rather than the billing account number.
Telemetry for each premise is served across three endpoints:
GET /jarvis/v3/smrd-uportal/{premise_id}: Returns the official cumulative meter readings (last_actual_valueandlast_actual_at) forelectric(kWh),water(m³), andgas(kWh).GET /jarvis/v4/charts/{premise_id}: Returns the monthly billed consumption history, reading status (Actualvs.Estimated), and neighborhood averages.POST /jarvis/v3/ami/charts: Returns Smart Meter (AMI) interval data—either48half-hourly slots for a single day (grouped_by: "day") or daily totals across a calendar month (grouped_by: "month").
There is a subtle nuance when feeding /jarvis/v3/smrd-uportal directly into Home Assistant's Energy Dashboard: last_actual_value only updates on the monthly billing read date (for example, 12,186.0 kWh on the 25th of the month).
To give Home Assistant a cumulative total_increasing electricity counter that updates every 30 minutes as smart meter data arrives, sp2mqtt computes a live cumulative kWh reading:
live_cumulative_kwh = meter_reading_at_t0 + sum(ami_daily_kwh for date > t0)
By taking the official cumulative reading anchored at last_actual_at and adding every subsequent day's AMI smart meter intervals (fetching both the previous month's and current month's daily AMI series whenever last_actual_at falls in the prior month), sensor.<prefix>_<premise>_electricity_cumulative_kwh advances smoothly every half hour while staying anchored to the official billing meter read.
System Architecture
Like iplusliving2mqtt, sp2mqtt is written in Rust as a single static binary using tokio, reqwest (rustls), and rumqttc:
┌──────────────────────────┐ ┌──────────────────────────┐
│ SP Digital Auth0 IDP │ │ SP Digital B2C APIs │
│ (identity.spdigital.sg) │ │ (b2c.api.spdigital.sg) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ 1. OAuth2 + MFA-OOB │ 2. /jarvis/*
│ & Token Refresh │ B2C REST
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ sp2mqtt │
│ │
│ ┌──────────────────────┐ ┌───────────────────────┐ │
│ │ Auth & Token Manager │────────▶│ B2C Telemetry Poller │ │
│ │ (JWT exp/state.json) │ │ (5xx Retry/Live kWh) │ │
│ └──────────▲───────────┘ └───────────┬───────────┘ │
│ │ Interactive │ Premise │
│ │ SMS OTP Flow │ Telemetry │
│ │ ▼ │
│ ┌──────────┴─────────────────────────────────────────────┐ │
│ │ Home Assistant MQTT Auto-Discovery Bridge │ │
│ │ (Account Auth Controls + 12 Premise Sensors) │ │
│ └────────────────────────────┬───────────────────────────┘ │
└───────────────────────────────┼──────────────────────────────┘
│ MQTT (homeassistant/#)
▼
┌───────────────────────┐
│ Home Assistant │
│ (Energy Dashboard) │
└───────────────────────┘
Event-Driven Concurrency & State Coordination
Internally, sp2mqtt runs three decoupled asynchronous tasks on the tokio runtime:
- Coordinator & Poller Loop: A
tokio::select!state machine that owns theSpClientandPersistedState. It wakes either on the 30-minute polling timer, on incomingDaemonCommandmessages (SendSmsOtp,SubmitOtp,PollNow) over a boundedtokio::sync::mpscchannel, or onSIGTERM/SIGINTfor graceful shutdown. - Reactive MQTT Bridge: Manages the persistent
rumqttcconnection, publishes retained discovery configurations, and subscribes to command topics (sp2mqtt/cmd/#) and Home Assistant's birth topic (homeassistant/status). Whenever the coordinator updates authentication state or premise telemetry in sharedReadinessState, it signals atokio::sync::Notifywaitlist so the MQTT task immediately flushes updated entity payloads without polling locks. - Observability & Control Server: A lightweight HTTP server on
:8080exposing/healthz,/readyz,/metrics.json, and an on-demandPOST /polltrigger.
Polite Polling & Proactive Token Refresh
Since AMI smart meter data settles in 30-minute intervals, sp2mqtt defaults to a polite 30-minute polling cadence (1800 seconds)—making only a handful of lightweight HTTPS requests per half hour. Before each cycle, the token manager inspects the exp claim inside both the access_token and id_token JWTs (refreshing proactively with a 5-minute safety margin) and persists updated credentials atomically via a temporary file (state.json.tmp → rename) so an unexpected pod eviction never corrupts the cached refresh token. Transient upstream 5xx gateway responses are handled with bounded exponential backoff.
Home Assistant Entities
Once connected to the MQTT broker, sp2mqtt registers two devices in Home Assistant with zero manual YAML configuration:
1. Account & Authentication Device (SP Services Account)
| Entity | Type | Description |
|---|---|---|
| Auth Status | sensor |
Authenticated, MFA Required, OTP Sent (...), or Error with diagnostic attributes. |
| Authenticated | binary_sensor |
ON when tokens are valid; OFF if interactive MFA is needed. |
| 1. Send SP SMS OTP | button |
Initiates Auth0 login and triggers an SMS OTP challenge to your registered phone. |
| 2. Submit SP 6-Digit SMS OTP | text |
Masked input box to submit the 6-digit SMS code directly from the Home Assistant UI. |
| Refresh Utility Usage Now | button |
Triggers an immediate on-demand telemetry poll across all premises. |
2. Per-Premise Utility Device (SP Premise <premise_id>)
| Entity | Class / State Class | Unit | Description |
|---|---|---|---|
| Electricity Cumulative (Live) | energy / total_increasing |
kWh |
Energy Dashboard primary sensor: Official meter reading + subsequent AMI intervals. |
| Electricity Meter Reading | energy / total_increasing |
kWh |
Official cumulative meter reading from smrd-uportal. |
| Electricity Today (AMI) | energy / total |
kWh |
Today's smart meter consumption across 48 half-hour slots. |
| Electricity Yesterday (AMI) | energy / measurement |
kWh |
Yesterday's smart meter total (with full 48-slot breakdown in attributes). |
| Electricity Month-to-Date (AMI) | energy / total |
kWh |
Current month's daily AMI total (with daily bars in attributes). |
| Electricity Latest 30m Slot | energy / measurement |
kWh |
Consumption during the most recently completed 30-minute AMI interval. |
| Electricity Latest 30m Power | power / measurement |
W |
Average load (kWh * 2000 W/kWh) during the latest 30-minute slot. |
| Electricity Billed Month | energy / measurement |
kWh |
Latest monthly billed electricity usage (Actual / Estimated + neighbor average). |
| Water Meter Reading | water / total_increasing |
m³ |
Energy Dashboard water sensor: Cumulative water meter reading. |
| Water Billed Month | water / measurement |
m³ |
Latest monthly billed water consumption. |
| Gas Meter Reading | energy / total_increasing |
kWh |
Energy Dashboard gas sensor: Cumulative piped town gas meter reading. |
| Gas Billed Month | energy / measurement |
kWh |
Latest monthly billed gas consumption. |
Kubernetes Runtime Architecture
Running a stateful IoT bridge in Kubernetes that also supports interactive MFA requires careful alignment between container security, volume lifecycle, and probe semantics:
- Hardened Distroless Runtime:
sp2mqttis compiled in a multi-stage build and packaged ontogcr.io/distroless/cc-debian13:nonroot. The pod runs as UID/GID65532withreadOnlyRootFilesystem: true,allowPrivilegeEscalation: false,automountServiceAccountToken: false, and all Linux capabilities dropped—mounting only a read-only ConfigMap at/etc/sp2mqttand a tiny PersistentVolumeClaim at/dataforstate.json. - Single-Writer Rollout Strategy (
Recreate): Because Auth0 refresh tokens and installation metadata are persisted on aReadWriteOncevolume, the Deployment usesstrategy.type: Recreateso two replicas never race to refresh or overwrite the same OAuth2 session concurrently during rollouts. - Decoupled Readiness vs. Auth State: If
/readyzrequired an activeaccess_token, Kubernetes would mark an unauthenticated pod unready—and if a liveness probe were tied to authentication, the kubelet would enter aCrashLoopBackOffright while the user is waiting for an SMS OTP. Instead,/healthzverifies that the Tokio runtime is responsive, while/readyzchecks that the MQTT broker connection is active (mqtt_connected: true). That way, the pod stays healthy and ready so it can expose the SP Services Account MFA controls in Home Assistant while awaiting initial OTP verification. - MQTT Last Will & Birth Synchronization: The MQTT client registers a Last Will and Testament (
sp2mqtt/status→offline) and subscribes tohomeassistant/status. If Home Assistant restarts whilesp2mqttis already running, receiving theonlinebirth message triggers an immediate re-broadcast of all discovery schemas and cached premise states so sensors never show up asunavailable.
If you're in Singapore and interested in how household smart meter, water, and gas telemetry can be bridged into Home Assistant's Energy Dashboard, feel free to reach out at [email protected].