🚪 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
86df4e607ba24613a2a74d787b5e1889461b2331d2d05180fa12323a45866b5d
|
|
| MD5 |
1ed973dfba2d29743c983466397c4e8d
|
|
| BLAKE2b-256 |
0130eb3cec5c9bfd9f40829961ecd3afc9f69b9b225820952b1a5919075b705b
|
Provenance
The following attestation bundles were made for urmet_sdk-0.2.0.tar.gz:
Publisher:
publisher.yml on fabienvauchelles/urmet-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
urmet_sdk-0.2.0.tar.gz -
Subject digest:
86df4e607ba24613a2a74d787b5e1889461b2331d2d05180fa12323a45866b5d - Sigstore transparency entry: 2424866476
- Sigstore integration time:
-
Permalink:
fabienvauchelles/urmet-sdk@b5fd2c184a70ad0bfb225f86714ab732f98679aa -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/fabienvauchelles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publisher.yml@b5fd2c184a70ad0bfb225f86714ab732f98679aa -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e0e2d51df28b467d7035ef37863672fe630d430092cf974f1569fae0df5928a2
|
|
| MD5 |
35c93b040b907ce3706b298207ee3f05
|
|
| BLAKE2b-256 |
f56ab9c5648530ef93afc45b6c8463190fd4be3abf1507527a9e83405c99f64a
|
Provenance
The following attestation bundles were made for urmet_sdk-0.2.0-py3-none-any.whl:
Publisher:
publisher.yml on fabienvauchelles/urmet-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
urmet_sdk-0.2.0-py3-none-any.whl -
Subject digest:
e0e2d51df28b467d7035ef37863672fe630d430092cf974f1569fae0df5928a2 - Sigstore transparency entry: 2424866574
- Sigstore integration time:
-
Permalink:
fabienvauchelles/urmet-sdk@b5fd2c184a70ad0bfb225f86714ab732f98679aa -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/fabienvauchelles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publisher.yml@b5fd2c184a70ad0bfb225f86714ab732f98679aa -
Trigger Event:
release
-
Statement type: