Skip to main content

🚪 urmet-sdk

A doorphone that offers no interface, turned into one you can program.

An Urmet video doorphone ships with no API, no webhook and no local integration: it speaks its own SIP protocol to its own cloud, and nothing else. This SDK speaks that same protocol, so your own code can watch the door, open the gate and log every ring.

A typed Python package, built and validated against a real 2Voice installation.

⚠️ Unofficial. An independent project, not affiliated with, authorised by or endorsed by Urmet S.p.A. Point it at hardware and a cloud account you own.

🧱 The problem

A door entry system is one of the few things in a house that is genuinely worth automating, and it is usually the one thing that refuses:

  • 🔔 The ring goes nowhere. Somebody presses the button, a phone chimes, and that is the end of it. No log, no event, nothing another program can react to.
  • 🚧 Opening the gate is a phone hunt. The single command you actually want is three taps deep inside an app you have to unlock first, while somebody waits in the rain.
  • 📷 The camera is walled in. There is a live H.264 stream sitting at the panel, and no way to pull one frame out of it into anything you already run.
  • 🏠 Nothing to build on. A doorbell history, a Home Assistant entity, a script that opens for a delivery you are expecting: all of it needs an interface that does not exist.

Underneath, the panel is a plain SIP endpoint registering to the vendor's Flexisip cloud, so "just point a softphone at it" sounds like an afternoon. It is not. The SIP credentials arrive encrypted inside a REST blob you have to log in for, media is refused unless it is SRTP, the panel is addressed by its MAC rather than by any name, and the open command is a SIP INFO carrying a DTMF body rather than any digit a softphone would ever send. Miss one of the four and you get a call that connects and a gate that never moves.

This SDK is those four, already handled, behind six method calls.

📦 Install

pip install urmet-sdk

Python 3.12 or newer. Runtime dependencies are pure Python (pydantic, pydantic-settings, requests), so the wheel installs anywhere and drags nothing native behind it.

Live SIP needs pjsua2, and pjsua2 is not on PyPI

Read this part before you plan your evening. The SIP stack is PJSIP, reached through its pjsua2 Python binding. That binding is generated by SWIG from a pjproject source tree, so it cannot be declared as a dependency of this package, and pip install pjsua2 gets you an unrelated, stale project instead. You build it yourself, once: pjproject 2.17, configured with TLS, SRTP and H.264. Every command, every flag, the config_site.h that matters and the half dozen traps worth knowing about are written down in docs/pjsua2-install.md. Budget an apt install and a few minutes of compiling.

Once that source tree exists, make pjsua2-wheel turns it into an installable wheel. It is worth using rather than calling pjproject's own build by hand, for two reasons that each cost an evening: pjproject invokes whatever python3 is on the PATH, which on a machine with a version manager is rarely the interpreter you are installing into, and its default target builds the extension without ever producing a wheel.

Importing the SDK does not need it. The native import is lazy, so the domain models, the settings, the cloud REST client and the whole test suite run on a plain pip install. Only live SIP is affected, and it fails with a typed error that names the missing binding rather than a stack trace out of C++.

🚀 Quickstart

Open the gate, and stay up to hear the doorbell:

import time

from urmet_sdk import CloudClient, PjsipTransport, RingEvent, Settings, UrmetClient

settings = Settings(
    email="you@example.com",
    password="your-urmet-cloud-password",
    doorphone_mac="00:11:22:33:44:55",
    doorphone_name="Front Gate",
)


def somebody_is_here(event: RingEvent) -> None:
    print(f"ring from {event.doorphone.label}, call {event.call_id}")


# Both planes are injected: the SDK constructs neither, and a caller can put
# its own implementation of either behind them.
client = UrmetClient(
    settings,
    cloud=CloudClient(settings.cloud_base_url),
    transport=PjsipTransport(settings),
)
client.on_ring(somebody_is_here)

with client:  # log in, provision, REGISTER, block until the registrar answers
    client.open_gate()  # in-dialog SIP INFO, Signal=2, and it waits for the 200 OK
    time.sleep(300)  # stay registered, take the rings

Settings also reads the environment, prefix URMET_, or a local .env, so credentials never have to sit in your source:

URMET_EMAIL=you@example.com
URMET_PASSWORD=your-urmet-cloud-password
URMET_DOORPHONE_MAC=00:11:22:33:44:55
URMET_DOORPHONE_NAME=Front Gate

Then Settings() takes no arguments at all.

See the door and open in the same dialog:

from urmet_sdk import Actuator

with client:
    call = client.view_door(want_video=True)  # INVITE, wait until media flows
    client.open_during(call, Actuator.DOOR)
    client.hangup(call)

To show the picture, hand the SDK a native window you already own and it draws into it:

from urmet_sdk import VideoSurface, WindowKind

surface = VideoSurface(handle=widget_window_id, kind=WindowKind.X11, width=640, height=480)
client.attach_video(call, surface)  # False while the call has no video stream yet

No frame ever crosses the boundary, and the SDK imports no toolkit: it takes the handle, the kind of handle it is, and its size.

One rule to keep in mind: on_ring and on_call_state fire on the SIP worker thread, not on yours. Push the event onto a queue or a signal and get out. Blocking there stalls the stack.

✨ What you can do

🚪 Open the door and the gate. open_gate() and open_door() drive the two actuators and return only once the panel has acknowledged. No live call needed: if none is up, one is placed and released around the command. An open that was not acknowledged raises, so a silent panel is never reported as a success.

📹 Look before you open. view_door() places the on demand call that 2Voice calls auto insertion, and blocks until media is really streaming, rather than returning the moment the INVITE leaves.

🔔 Know the moment somebody rings. on_ring(cb) fires on every inbound call from the panel, with the doorphone identified and the call named. That is the hook a doorbell log, a push notification or a home automation rule has been missing.

🎙️ Answer and talk. answer(call) picks up a ring with bidirectional SRTP audio, and hangup(call) ends it. Mid-call, open_during(call, actuator) lets you talk to the courier first and open second. set_mic_muted() really disconnects the microphone from the call and mic_muted reads that state back, so an interface can never claim a privacy the stack has not got. audio_levels(call) measures both directions off the conference bridge, and audio_route() names the devices actually opened.

🔑 Credentials sort themselves out. registered says whether the registrar currently holds a binding, and registration_status_code and registration_reason say what it actually answered, so a caller reporting a refusal reports the registrar's own words rather than a code it invented. start() logs into the cloud, obtains SIP credentials and registers, in one call. By default it reuses the account already on file, decrypted from the cloud blob; the registrar keeps several bindings per account, so your phones stay registered next to you. Flip dedicated_account and it mints a separate account instead. If the registrar ever stops recognising the stored account, which is what a rotated account looks like, the blob is decrypted again and tried once more rather than retried for ever on a dead credential.

🧪 Typed, and testable without hardware. Full type hints, a py.typed marker, mypy with undefined functions rejected, and a typed error for every failure mode: bad login, provisioning, registration refused, call lost, open not acknowledged. The test suite runs whole scenarios end to end against protocol doubles, with no network and no pjsua2 anywhere near it, and those doubles ship with the package so your own tests get the same deal (see below).

🧨 Three protocol facts that will bite you

They are not preferences, they are what the hardware enforces. All three are handled for you, and all three explain an error you may still hit.

SRTP or nothing. The panel offers RTP/SAVP with AES_CM_128_HMAC_SHA1_80 and rejects plain RTP outright. If your pjsua2 build was compiled without SRTP, the call sets up and the media never arrives. This is why the build guide insists on the flag.

Opening is a SIP INFO, not a digit. The open command is an in-dialog INFO with an application/dtmf-relay body: Signal=1 for the pedestrian door, Signal=2 for the sliding gate. It is never RFC 2833 in-band DTMF, which is what a softphone sends by default and what the panel silently ignores. In-dialog also means an open needs a call, which is why open_gate() places one when there is none.

The panel is its MAC. Its SIP user is the MAC with the colons turned into underscores, so 00:11:22:33:44:55 becomes 00_11_22_33_44_55, and the same MAC rides along in a custom mac: header. Doorphone takes either form, in any case, and normalises. It is also how an inbound INVITE is recognised as a doorbell rather than a random call.

🧩 Two boundaries, both injected, and one more for a gateway

UrmetClient never constructs a plane and never opens a socket. It depends on SipTransport, a typing.Protocol listing exactly what this protocol needs and nothing more: register, invite, answer, hangup, the open INFO, a SIP MESSAGE, the video surface, the audio levels and the microphone, and three callbacks. PjsipTransport implements it on pjsua2, and the import pjsua2 lives inside the method that first needs it. The cloud side is the same shape: CloudPlane is three calls, sign in, read the credential blob, mint an account, and CloudClient implements them over REST.

That single decision is what makes the rest of it pleasant. Business logic that never imports pjsua2 can be exercised at full speed against a fake, which is how every scenario in the test suite runs. Nothing is welded to PJSIP either: a different SIP stack is a new class satisfying the same Protocol, not a fork. And the same core is what a Home Assistant integration would drive, unchanged.

📼 And a third, for anything that is not a screen and a speaker

MediaTap is the boundary a gateway needs and a desktop never touches: where one call's decoded media goes when there is no window to draw it in and no microphone to speak into. Five calls, no native type on any of them.

open_video_tap(call, path, max_bytes) writes the decoded picture into a path you own, uncompressed, and returns the geometry it was built at; close_video_tap releases it, and on_video_format(cb) tells you when the panel changed resolution, which is when the recorder and whatever reads it have to be built again. If the path is a named pipe, have your reader draining it before you arm the tap: opening one for writing waits for a reader, and uncompressed video is about 14 MB/s at 320x240 and eight times that at the size an answered ring sends.

A call that has just started carrying media has no picture yet, and asking for one raises. The two refusals are different types on purpose: a plain CallError means the stream is not up yet and asking again a second later gets it, while NoVideoOfferedError means the dialog carries no video line at all and no amount of asking will change that. A caller that cannot tell them apart either gives up on a picture that was seconds away or waits for one that can never arrive.

attach_audio_tap(call, sink) puts an AudioSink of yours in both directions and says what PCM it will see, 8 kHz mono in 20 ms frames. Both of its methods are called from the media clock thread, while it holds the bridge's lock, fifty times a second in each direction, so they copy bytes and return: no waiting, no lock the rest of your process can hold, no buffer that grows. The uplink follows set_mic_muted exactly as a capture device does, so mic_muted stays the one value that says whether the doorphone can hear anything.

PjsipTransport implements this beside SipTransport, so a gateway holds one object and types each reference by what it needs. Set URMET_NULL_SOUND_DEVICE=true with it: the stack then opens no card at all, and a machine forwarding a visitor's voice never takes hold of its own microphone. The default is false, which is the desktop behaviour this SDK has always had.

🧪 The doubles ship with the package

urmet_sdk.testing is the two boundary doubles, published rather than hidden in the test tree. Your tests build a real UrmetClient over them, so the orchestration under test is the SDK's own and only the wire is fake:

import pytest

from urmet_sdk import Actuator, OpenNotAcknowledgedError, UrmetClient
from urmet_sdk.testing import FakeCloud, FakeSipTransport

transport = FakeSipTransport(open_acknowledged=False)  # a panel that never confirms
client = UrmetClient(settings, cloud=FakeCloud(profile=profile), transport=transport)

with client:  # logs in, provisions and registers, all against the doubles
    with pytest.raises(OpenNotAcknowledgedError):
        client.open_gate()

# The INFO really left, with the body the panel would have had to answer.
assert [record.actuator for record in transport.opens] == [Actuator.GATE]

FakeSipTransport records every request (invites, opens, messages, registrations, hung_up) and drives the doorphone through simulate_ring, simulate_incoming, expire_registration and rebuild. FakeCloud plays the account plane down to a real encrypted sipdata blob, which encrypt_sipdata builds for you. FakeMediaTap plays the media stack, clock thread included: run_audio drives your AudioSink from a foreign thread, at full speed and with no patience, so a sink that grows instead of dropping or waits instead of returning fails there rather than on a live call.

Callbacks are delivered from a worker thread, the way a real stack delivers them, so drain() is the handshake before an assertion on an event. It blocks: from asyncio, run it and the simulate hooks through loop.run_in_executor, never on the loop thread.

📊 Status and scope

Honest version, so nobody wastes a weekend.

Proven against the real cloud. Logging in, pulling the encrypted credential blob and decrypting it, and registering on sip.urmet.com over TLS with SRTP available: all of that runs against the vendor's production servers today, and the registrar accepts the binding alongside the phones already on the account.

Signalling reaches the panel. An outbound call is routed by the vendor proxy, reaches the doorphone, and the doorphone answers. SRTP is negotiated over SDES on AES_CM_128_HMAC_SHA1_80, which is the only suite the panel accepts.

Media is carried, and seen. An on demand call streams SRTP audio and H.264 video against the real panel, the picture renders inside the caller's own window, and the microphone reaches the panel.

The actuators and the doorbell run against real hardware. open_door unlocks the pedestrian door and open_gate drives the sliding gate, each acknowledged by the panel. A doorbell press arrives as an inbound call, answer takes it, both directions of audio flow, and the visitor is on screen. That last part is the one thing that needs a build-time flag: the panel offers its camera one way, so nothing is ever encoded on that stream, and the vendor's media relay only sends where it has already seen packets come from. PJSIP's own NAT hole punching solves it, and the install guide says which flag turns it on.

Known gaps. There is no still-image API. The SDK draws into a window you own, or writes the decoded stream into a path you own through MediaTap, and never hands a single frame back, so take one from whichever of those two you armed. The tap boundary itself is published and its native side loads, but it has not been exercised against the panel end to end yet: the recorder and the pipe were proven in a spike, and the port this SDK builds on top of them has not been on a live call. Live SIP also needs a small local patch to pjproject, because this vendor serves a wildcard TLS certificate that PJSIP refuses under RFC 5922; the patch and the reasoning are in the install guide.

Scope. This is 0.1.0, alpha, validated against exactly 1 hardware family: the Mini Note kit, 2Voice, with a 1083 series module. Other Urmet gateways may or may not answer the same way. The cloud path is the only one supported; a LAN only path was probed and never validated. And this depends on a vendor cloud that can change under you at any time, with no warning and no obligation to you at all.

Use it on your own hardware, on your own account. The credentials it decrypts are yours alone, so keep them out of anything you publish.

📚 Documentation

Reference material lives in docs/. Start with pjsua2-install.md if you are setting up, since that is the only non-trivial part of the install.

The protocol itself is specified in specs/, eight documents laid out like RFCs and detailed enough to write a client from: sign-in and credentials, registration and transport, the device messaging layer, calls and media, actuator control, and the inbound doorbell. Start at 00-overview-and-terminology.md, which fixes the vocabulary the other seven use. They say where a behaviour was confirmed against real equipment and where it was not, which is the difference that matters when you are debugging at two in the morning.

📜 Licence

FSL-1.1-MIT. Source available, not open source: read it, fork it, run it, build on it and ship products with it, with one restriction, you may not use it to make a competing product. Every release converts to plain MIT 2 years after it ships, automatically.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

urmet_sdk-0.2.0.tar.gz (112.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

urmet_sdk-0.2.0-py3-none-any.whl (134.2 kB view details)

Uploaded Python 3

File details

Details for the file urmet_sdk-0.2.0.tar.gz.

File metadata

  • Download URL: urmet_sdk-0.2.0.tar.gz
  • Upload date:
  • Size: 112.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for urmet_sdk-0.2.0.tar.gz
Algorithm Hash digest
SHA256 86df4e607ba24613a2a74d787b5e1889461b2331d2d05180fa12323a45866b5d
MD5 1ed973dfba2d29743c983466397c4e8d
BLAKE2b-256 0130eb3cec5c9bfd9f40829961ecd3afc9f69b9b225820952b1a5919075b705b

See more details on using hashes here.

Provenance

The following attestation bundles were made for urmet_sdk-0.2.0.tar.gz:

Publisher: publisher.yml on fabienvauchelles/urmet-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file urmet_sdk-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: urmet_sdk-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 134.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for urmet_sdk-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e0e2d51df28b467d7035ef37863672fe630d430092cf974f1569fae0df5928a2
MD5 35c93b040b907ce3706b298207ee3f05
BLAKE2b-256 f56ab9c5648530ef93afc45b6c8463190fd4be3abf1507527a9e83405c99f64a

See more details on using hashes here.

Provenance

The following attestation bundles were made for urmet_sdk-0.2.0-py3-none-any.whl:

Publisher: publisher.yml on fabienvauchelles/urmet-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page