Metadata-Version: 2.4
Name: urmet-sdk
Version: 0.1.0
Summary: SIP protocol SDK for Urmet video doorphones: ring events, door and gate control, audio and video.
Author: Fabien Vauchelles
License-Expression: LicenseRef-FSL-1.1-MIT
Project-URL: Homepage, https://github.com/fabienvauchelles/urmet-sdk
Project-URL: Repository, https://github.com/fabienvauchelles/urmet-sdk
Project-URL: Issues, https://github.com/fabienvauchelles/urmet-sdk/issues
Keywords: sip,voip,srtp,urmet,doorphone,intercom,pjsip
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic>=2.7
Requires-Dist: pydantic-settings>=2.2
Requires-Dist: requests>=2.32
Provides-Extra: dev
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: pytest>=8.2; extra == "dev"
Requires-Dist: types-requests; extra == "dev"
Dynamic: license-file

# 🚪 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

```bash
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](https://github.com/fabienvauchelles/urmet-sdk/blob/main/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:

```python
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:

```bash
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:

```python
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:

```python
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:

```python
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/](https://github.com/fabienvauchelles/urmet-sdk/blob/main/docs). Start with
[pjsua2-install.md](https://github.com/fabienvauchelles/urmet-sdk/blob/main/docs/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/](https://github.com/fabienvauchelles/urmet-sdk/blob/main/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](https://github.com/fabienvauchelles/urmet-sdk/blob/main/specs/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](LICENSE). 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.
