Bridging a Cloud Condo Intercom to Home Assistant with Rust, SIP, and MQTT

In a well-tuned smart home, almost every physical interaction is programmable. Lighting scenes adapt to the time of day, climate control reacts to occupancy, and alerts route cleanly through a single dashboard in Home Assistant.

Yet in many modern condominiums in Singapore, one piece of hardware stubbornly resists integration: the building intercom.

Instead of exposing a standard local LAN doorphone inside the apartment, many residential developments route their lobby call panels through cloud-managed property management apps—such as iPlus Living backed by a Fermax MeetMe SIP infrastructure. When a visitor or delivery driver dials your unit from the lobby or side gate, your phone receives a push notification from a proprietary app, you wait for the app to cold-start and negotiate a video stream, and you tap an icon to buzz the gate open.

From a home automation perspective, this is a dead end:

  • Siloed notifications: You cannot route doorbell events or camera snapshots into Home Assistant notifications, wall tablets, or TVs.
  • No programmable access: You cannot trigger an unlock from a Home Assistant dashboard, an NFC tag, or a temporary "delivery window" automation.
  • Single-platform lock-in: Everything depends on a mobile app staying logged in and surviving aggressive mobile OS background battery killers.

I wanted the condo intercom to behave like any other native device in my cluster. So I built iplusliving2mqtt—a self-contained Rust daemon running in Kubernetes that bridges the entire intercom lifecycle into Home Assistant via MQTT Auto-Discovery.


Why Standard SIP Clients Don't Work

At first glance, a SIP-based intercom sounds trivial: grab the SIP credentials, point baresip, Asterisk, or Home Assistant's VoIP integration at the server, and wait for an incoming INVITE.

In practice, inspecting the protocol flow revealed three layers that prevent a standard SIP client from ever receiving a call:

1. Split-Key Encrypted Provisioning API

Rather than giving residents static SIP credentials, the iPlus Living mobile app dynamically provisions them after login. Every HTTP request and response body is encrypted with AES-256-CBC using a fixed initialization vector, while the 32-byte AES key is hidden inside a 42-byte random alphanumeric prefix prepended to the base64 ciphertext (payload[:16] + payload[22:38]).

Authenticating through the four-step login sequence (user/login → user/logindetail → intercom/meetmelicencedetails → intercom/intercomconfiguration) returns:

  • Cloud MeetMe SIP credentials (sip.fermax.com)
  • Building PBX SIP credentials (the development's Grandstream server)
  • The call panel directory (mapping numeric SIP caller IDs like 14 or 22 to human-readable gate and lobby names)

2. The Push Notification Catch-22 (RFC 8599)

Even with valid SIP credentials and an active TLS connection to Fermax's Flexisip proxy (_sips._tcp.sip.fermax.com), a standard SIP client will sit idle forever when someone presses the doorbell.

Why? Because the Flexisip server is configured for mobile push notifications under RFC 8599 with pn-timeout=0:

Contact: <sip:USER@IP:PORT;pn-prid=FCM_TOKEN;pn-provider=fcm;pn-param=SENDER_ID;pn-silent=1;pn-timeout=0;transport=tls>

With pn-timeout=0, the SIP proxy assumes mobile clients go to sleep immediately after registering. When the lobby panel places a call:

  1. Flexisip pauses the incoming INVITE transaction.
  2. It dispatches a high-priority data push (title: "IC_CALL") through Google Firebase Cloud Messaging (FCM) to the pn-prid token registered in the Contact header.
  3. It waits for the client to wake up and send a fresh SIP REGISTER over TLS.
  4. Only after receiving that REGISTER does Flexisip release the held INVITE to the client.

Without a real FCM token and a persistent listener on Google's Mobile Connection Server (mtalk.google.com:5228), the SIP server never forwards the call.

3. Early-Media H.264 Video & Dual-Mode DTMF

Once the INVITE arrives, the lobby panel streams H.264 video during early media (183 Session Progress) before the call is answered. However, the door relay controller ignores unlock commands during early media—to actually buzz the gate open, the client must transition the call to answered (200 OK with SDP), wait for the ACK, and transmit DTMF unlock tones before hanging up (BYE).


System Architecture

To make this rock-solid in Kubernetes without relying on external headless Android emulators, ffmpeg subprocesses, or Python scripts, I built iplusliving2mqtt as a single static Rust binary:

  ┌──────────────────────────┐        ┌──────────────────────────┐
  │    iPlus Living API      │        │   Google FCM / MCS       │
  │  (AES-256-CBC Provision) │        │ (mtalk.google.com:5228)  │
  └────────────┬─────────────┘        └────────────┬─────────────┘
               │ 1. Fetch SIP + Panels             │ 2. Push "IC_CALL"
               ▼                                   ▼
  ┌──────────────────────────────────────────────────────────────┐
  │                      iplusliving2mqtt                        │
  │                                                              │
  │  ┌──────────────────────┐         ┌───────────────────────┐  │
  │  │  Provisioning State  │         │  Native FCM Listener  │  │
  │  │     (state.json)     │         │  (Protobuf over TLS)  │  │
  │  └──────────┬───────────┘         └───────────┬───────────┘  │
  │             │                                 │ Wake Signal  │
  │             ▼                                 ▼              │
  │  ┌────────────────────────────────────────────────────────┐  │
  │  │            Dual-Account SIP/TLS Engine                 │  │
  │  │        (MeetMe Cloud + Building Grandstream)           │  │
  │  └──────────┬─────────────────────────────────┬───────────┘  │
  │             │ RTP H.264 + RTCP PLI            │ SIP Events   │
  │             ▼                                 ▼              │
  │  ┌──────────────────────┐         ┌───────────────────────┐  │
  │  │   OpenH264 Decoder   │────────▶│  MQTT Auto-Discovery  │  │
  │  │   & JPEG Encoder     │         │   & Command Bridge    │  │
  │  └──────────────────────┘         └───────────┬───────────┘  │
  └───────────────────────────────────────────────┼──────────────┘
                                                  │
                                                  ▼
                                      ┌───────────────────────┐
                                      │    Home Assistant     │
                                      │ (Camera, Lock, Ring)  │
                                      └───────────────────────┘

Zero-Touch Provisioning & FCM Emulation

On startup, if no cached state exists on its persistent volume, iplusliving2mqtt performs a two-stage bootstrap:

  1. FCM Registration: Emulates an Android check-in against android.clients.google.com/checkin and registers with Google Cloud Messaging (/c2dm/register3) using the official app's Firebase project parameters to obtain a valid fcm_token.
  2. iPlus Auto-Provisioning: Logs into the iPlus Living API using the resident's email and password, binds the fcm_token, and caches the resulting SIP accounts and call panel directory to state.json.
  3. MCS Stream: Opens a persistent TLS connection to mtalk.google.com:5228 speaking Google's binary MCS framing + Protobuf protocol. The moment an IC_CALL data message arrives, it signals the SIP worker tasks over an internal tokio::sync::mpsc channel to fire an immediate SIP REGISTER.

In-Memory H.264 Keyframe Extraction

Lobby panels stream H.264 video encapsulated in RTP packets (RFC 6184), including fragmented NAL units (FU-A, type 28) and aggregation packets (STAP-A, type 24).

Rather than spawning ffmpeg on every doorbell ring, iplusliving2mqtt handles the media stream directly in memory:

  • Sends UDP NAT-punching packets plus an RTCP Picture Loss Indication (PLI) and a SIP INFO (picture_fast_update) request immediately upon answering with 183 Session Progress so the panel transmits an IDR keyframe right away.
  • Reassembles fragmented H.264 NAL units (SPS, PPS, and IDR slices) and feeds the Annex-B bitstream into Cisco's openh264 decoder in memory.
  • Converts the decoded YUV420p frame to RGB and encodes a JPEG snapshot directly onto the MQTT camera topic (fermax/intercom/snapshot/image) and an HTTP /snapshot.jpg endpoint.

Dual-Mode DTMF Unlock

Different intercom relays along the call path can be picky about how DTMF digits are delivered. When you press the Unlock button in Home Assistant (or when auto-unlock is enabled), the daemon:

  1. Answers the pending INVITE with 200 OK (if still in 183 Session Progress).
  2. Transmits RFC 2833 (telephone-event/8000) RTP payload packets with the end-of-event marker bit and triple redundancy.
  3. Follows up with synthesized in-band G.711 µ-law (PCMU) dual-tone sine waves (697–941 Hz + 1209–1477 Hz) at 8,000 Hz so both digital SIP gateways and analog door strike controllers reliably trigger.
  4. Sends a clean SIP BYE to release the lobby panel.

A Fun Dual-Stack Debugging Quirk: IPv6 vs. IPv4 in SIP

When testing the daemon on a dual-stack host with native IPv6 connectivity, the initial SIP TLS connection to sip5.fermax.com:5223 completed its TLS handshake and then immediately dropped with an EOF.

Digging into the packet trace uncovered two separate issues:

  1. RFC 3261 IPv6 Formatting: sip5.fermax.com publishes both A and AAAA records. Because the host preferred IPv6, tcp.local_addr() returned an IPv6 address. Formatting Via and Contact headers as ip:port without enclosing the IPv6 literal in square brackets ([2a09:...]:5060) caused Flexisip's parser to reject the REGISTER packet immediately.
  2. IPv4-Only Media Relays: Even with bracketed IPv6 headers, the building's local PBX and the RTP audio/video media relays (c=IN IP4 ... in SDP) are strictly IPv4. Because the SIP engine learns its external NAT address from the SIP server's Via: ...;received=<ip>;rport=<port> response header to advertise in the SDP answer, registering over IPv6 leaves the media engine with an IPv6 address that an IPv4 RTP relay can never reach.

Explicitly resolving and selecting IPv4 socket addresses for the SIP/RTP path immediately brought both the MeetMe and Grandstream registrations to 200 OK.


Home Assistant Integration

Because iplusliving2mqtt publishes Home Assistant MQTT Auto-Discovery payloads under homeassistant/#, zero manual YAML configuration is required in Home Assistant. All entities automatically group under a single Fermax MeetMe Intercom device:

Entity Type Description
Doorbell Ringing binary_sensor Turns ON the instant an incoming INVITE arrives from any gate or lobby panel.
Call State sensor Tracks the call lifecycle: idle → ringing → answered → unlocked.
Last Caller sensor Displays the resolved lobby or gate name (e.g., Side Gate or Block Lobby) plus JSON attributes.
Intercom Camera camera Live JPEG snapshot decoded from the panel's H.264 RTP video stream.
Unlock Door button Immediately answers the active call and sends the DTMF unlock sequence.
Door Lock lock Reflects LOCKED / UNLOCKED state and supports standard Home Assistant lock actions.
Auto-Unlock on Ring switch Optional toggle that automatically answers and buzzes the door open after a configurable delay.

With everything in Home Assistant, a doorbell ring now triggers an instant push notification to our phones with the live decoded camera snapshot attached and an inline "Unlock Gate" action button—plus a temporary "expecting a delivery" toggle that auto-unlocks the lobby gate for the next ring and then turns itself off.


Kubernetes Deployment

True to the rest of my home infrastructure, iplusliving2mqtt is packaged as a minimal multi-stage container (gcr.io/distroless/cc-debian13:nonroot, running as UID 65532 with a read-only root filesystem and all Linux capabilities dropped) and deployed via a Helm chart with /healthz and /readyz probes that verify both MQTT broker connectivity and active SIP registration state.