A Python reference implementation of an NMOS Node with Matrox NMOS extensions.
An NMOS Node implementation — covering the AMWA NMOS Interface Specifications (IS-04, IS-05, IS-10, IS-11), a curated set of AMWA Best Current Practices, and selected VSF Technical Recommendations — written in typed Python on top of asyncio + aiohttp. It is intended as both a working device and a teaching reference.
- NMOS Interface Specifications — Discovery (IS-04), Connection (IS-05), Authorization (IS-10), and Stream Compatibility (IS-11; excludes the
Input/Outputresources). - Capabilities-driven controller — the embedded NMOS Controller consumes BCP-004-01/-02 capabilities and drives IS-11 negotiation through the Matrox Capability Constraint Framework (CCF) in
caps/, reconfiguring Senders / Receivers at runtime against what peers actually advertise rather than against prebaked SDP templates. See Specification coverage. - Hierarchical mux capabilities — transport / container layering for MPEG2-TS, NDI, SRT, and RTSP, plus the AM824 / AES3 audio mux container, with IS-11 negotiating each sub-flow / sub-stream (video, audio, data) independently against the peer's per-layer constraints. Audio sub-flows can be PCM, AAC, or AM824; video sub-flows raw, JPEG XS, H.264, or H.265 — all selected by capability matching. See One Model.
- BCP-008 status reporting over IS-04 (no IS-12 / MS-05-02 dependency) — Sender / Receiver status flows through IS-04 registration as monitor resources, so any registry-subscribed controller observes changes without implementing the NC control-protocol stack. See Specification coverage.
- VSF Technical Recommendations — TR-10-13 (privacy-encrypted transport) and TR-10-14 (USB-over-IP and capability sets).
- Three security configurations, each with launch scripts and test coverage:
- Config A — Mutual TLS, no OAuth 2.0
- Config B — OAuth 2.0 with server TLS
- Config C — Mutual TLS + OAuth 2.0
- TLS — cipher whitelist enforcement, ECDH curve restriction, configurable CRL (GCRL) for revocation handling.
- Typed JSON serializers — generated from the NMOS JSON schemas, so the wire format and the in-memory types stay in sync.
- Typed Python —
mypy --strictclean across thenmos/package and the vendored Authorization Server infake-as/(tests excluded). - Asyncio throughout — aiohttp HTTP / WebSocket servers,
DispatchGroup-based task lifecycles, errgroup-style cancellation. - Bundled IS-04 registry —
nmos_registry.pyserves the Registration and Query APIs (HTTP + WebSocket) so the whole system runs from this checkout with no third-party registry to install. See NMOS Registry. - Test suite — 2 900+ tests across unit, integration, and end-to-end paths.
Nothing outside this checkout is required. The repository ships its own IS-04 registry, so a full multi-Node system — registry, Nodes, Controller — comes up from three terminals:
# Prerequisites: Python 3.12 or newer, plus the dependencies in pyproject.toml
pip install -e .[dev]
./start-registry-bare.sh # terminal 1 — IS-04 Registration + Query APIs
./start-node1-bare.sh # terminal 2
./start-node2-bare.sh # terminal 3Then open http://127.0.0.1:5050/controller/ and sign in with the password
admin. The Controller discovers both Nodes through the registry and updates
live over the registry's WebSocket. See NMOS Registry.
For TLS and OAuth 2.0 — the fully secured rig — see Secured quick start.
| Component | Config A (start-node1-noauth2.sh) |
Config B (start-node1-nomtls.sh) |
Config C (start-node1.sh) |
|---|---|---|---|
| NMOS Registry (IS-04) | optional | optional | optional |
| OAuth 2.0 Authorization Server (IS-10) | not used | required | required |
| TLS server certificate | required | required | required |
| TLS client certificate | required (mTLS) | not used | required (mTLS) |
Notes on the dependencies:
-
NMOS Registry: a single Node runs standalone with no registry — pass
--rdsHost ""(the launch-script default already wires this when no$3 $4are supplied). In this mode the embedded NMOS Controller seeds its cache once at startup from the local Node's resources, so the Controller UI shows the initial set of senders / receivers / sources / flows. The cache is not live-updated afterwards — IS-05 activations, IS-11 reconfigurations, BCP-008 status changes that happen at run-time will not appear in the Controller UI until you point the Node at a real registry. To exercise multi-Node negotiation AND see live updates, run the registry that ships with this repository (./start-registry.sh) or any other IS-04-compliant registry such as nmos-cpp's, passing$3 $4positional args on the launch script. -
OAuth 2.0 Authorization Server: required for Configs B and C — the Node fetches JWKS from the AS, validates Bearer tokens against the published public keys, and enforces the IS-10 claim semantics (
aud,scope,x-nmos-*). Any IS-10-compliant AS works; a Keycloak realm is a common choice for production deployments. Pass the AS host / port to the launch script as$1 $2. Config A does not contact an AS. -
TLS material: each launch script references a server cert / key (and, for mTLS, a client cert / key) and a trust root. Vendors substitute their own PKI by editing the scripts or by running
nmos_node.pydirectly with--nodeCertificate/--nodeKey/--nodeTrustedRootCA.
The Node also supports a no-TLS dev mode that sits outside Configs A/B/C — plain HTTP on every surface, no OAuth, no client-cert verification. It is not certifiable under any security spec (under NMOS With Control Plane Security a device shall not claim compliance while so configured), but is useful for quick connectivity experiments without PKI setup.
Run nmos_node.py directly with the disable flags:
python3 nmos_node.py \
--nodeDisableTLS --rdsDisableTLS --oauth2DisableTLS \
--nodeAddr 127.0.0.1 --nodePort 5050 \
--nodeControlPort 8080 --controllerAdminPassword adminThe full flag surface is documented by --help.
Verify the install by running the test suite: python3 -m pytest -q — see Tests.
TLS and OAuth 2.0 everywhere, two Nodes, no Keycloak and no Docker. Read Required before any TLS configuration first — without the hosts-file entries every component fails to verify its peers.
Four terminals, and the order matters: the Nodes need both the Authorization Server and the registry to be up before they start.
# terminal 1 — OAuth 2.0 Authorization Server on 9443
./start-fake-as.sh
# terminal 2 — IS-04 registry, mutual TLS (RAP=2)
./start-registry.sh 2
# terminal 3 — Node 1 (SNX00001) + the Controller UI on 5050
./start-node1.sh XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2
# terminal 4 — Node 2 (SNX00002)
./start-node2.sh XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2For a third Node, add a fifth terminal — start-node3.sh takes the same
arguments and policy flags:
./start-node3.sh XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2Three Nodes make the inaccessible-device case reachable, which two cannot. Start the Authorization Server scoped to a subset instead:
./start-fake-as.sh --serial=SNX00001 --serial=SNX00002Every token then names those two in its aud, so the Controller may configure
Nodes 1 and 2 while Node 3 — discovered through the registry all the same — is
shown as inaccessible with its controls disabled up front rather than failing
403 on the first click. --serial is repeatable; add --serial=SNX00003 and
the same rig becomes three configurable Nodes.
The positional arguments are <as-host> <as-port> <rds-host> <rds-port>, so
both Nodes are told to reach the Authorization Server at XYZ-SNX00000:9443
and the registry at XYZ-SNX00000:8444. Give the registry its certificate
name, not 127.0.0.1 — under --rap=1 or --rap=2 the Node verifies the
registry's certificate, and an IP literal matches no DNS SAN in it.
Then open https://XYZ-SNX00001:5050/controller/ and sign in twice:
| Gate | Credentials |
|---|---|
| The Controller's own password form | password admin |
| The Authorization Server it redirects you to | tr-10-sec-operator / admin |
Your browser will warn about the certificate — the shipped PKI is a private test
CA that no browser trusts. Accept the warning once per origin: the Controller on
XYZ-SNX00001:5050, and the Authorization Server on XYZ-SNX00000:9443 that
sign-in redirects to. Do not add the test root to a system or browser trust
store — it ships with the project, so everyone running it has the same root and
its private key is not yours to control; trusting it would cover any site
presented by whoever holds that key, not just this rig.
Node 2 is worth starting even if you only care about one Node. The token this
rig issues is scoped to SNX00001, so on the Senders or Receivers
page Node 2's device block shows the refusal and its reason:
OAuth2 token grants do not cover device serial 'SNX00002';
current aud entries: 'XYZ-SNX00001'
Per-device token scoping made visible, which is what the
tutorial-security walkthrough is built around — see Tutorials.
For a single Node in the other two profiles:
# Config A — mTLS without OAuth 2.0 (no Authorization Server needed)
./start-node1-noauth2.sh
# Config B — OAuth 2.0 with server TLS
./start-node1-nomtls.shBoth take the same positional arguments. With no registry arguments a Node
runs standalone — see the note under
External dependencies of the launch scripts.
nmos_node.py --help documents the full flag surface.
You anti-virus may prevent you from testing NMOS-Reference with TLS Some anti-virus software performs HTTPS scanning, in which it intercepts HTTPS communications within and outside the computer and inserts itself into the communication path and the TLS trust chain. This can prevent Nodes from communicating and authenticating themselves as expected.
Under Windows, it is best to perform TLS testing under WSL. Under Linux-based operating systems, there should normally be no problem unless similar HTTPS interception is performed by installed security tools.
Every TLS configuration reaches its peers by DNS name, never by IP address. The
shipped certificates carry DNS SANs of the form XYZ-SNX000nn (plus a .local
variant), and RFC 6125 hostname verification compares the name in the URL against
those SANs. An IP literal matches no DNS SAN, so https://127.0.0.1:8443 fails
verification even though it reaches the right socket:
SSLCertVerificationError: IP address mismatch,
certificate is not valid for '127.0.0.1'
Add these to /etc/hosts before running anything with TLS:
127.0.0.1 XYZ-SNX00000
127.0.0.1 XYZ-SNX00001
127.0.0.1 XYZ-SNX00002
| Name | Used by |
|---|---|
XYZ-SNX00000 |
NMOS Registry, and the OAuth 2.0 Authorization Server — the reserved infrastructure serial |
XYZ-SNX00001 |
Node 1 and its Controller UI |
XYZ-SNX00002 |
Node 2 |
XYZ-SNX00003 |
Node 3 |
Certificates ship for all four serials, so nothing outside this checkout is
needed. Further Nodes follow the same pattern, but their certificates do not
ship — point IPMX_CERT_ROOT at a Certificates/ tree that carries them.
This applies to how components address each other, not just to your browser.
Passing 127.0.0.1 as a launch script's registry-host argument fails under RAP=2
for exactly the reason above — use ./start-node1.sh XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2,
not 127.0.0.1.
The no-TLS launchers (*-bare.sh) bind 127.0.0.1 directly and need none of this.
Every launcher binds loopback only — 127.0.0.1 inside the WSL
distribution, never the WSL interface address. WSL2 forwards Windows
localhost to the distribution's loopback, and that forwarding is the whole
mechanism: do not point anything at the address wsl.exe hostname -I
prints, because nothing is listening there.
For a bare (no-TLS) rig, nothing is needed:
http://localhost:5050/controller/
For any TLS rig, the browser must use the certificate's own name — both so
TLS verification passes and so the OAuth 2.0 redirect_uri matches one the
Authorization Server has registered. Add to
C:\Windows\System32\drivers\etc\hosts, editing it as Administrator, and note
these are 127.0.0.1 rather than the WSL address:
127.0.0.1 XYZ-SNX00000
127.0.0.1 XYZ-SNX00001
127.0.0.1 XYZ-SNX00002
Then browse to https://XYZ-SNX00001:5050/controller/. XYZ-SNX00000 has to
resolve on Windows too, even though you never type it: under Configuration B
or C the Controller redirects your browser there to sign in.
The certificates are issued by a private test CA, so every TLS origin will warn. Accept the warning for each origin. Do not install the test root into the Windows certificate store. That root ships with the project, so every user of it has the same one, and its private key is not under your control — trusting it machine-wide would let anyone holding that key present a trusted certificate for any site to this machine, long after you are done with the rig. A per-origin exception costs a few clicks and goes away with the browser profile.
Under Configuration B or C there are two origins to accept: the Controller on
XYZ-SNX00001:5050, and the Authorization Server on XYZ-SNX00000:9443 that
sign-in redirects to. Visiting https://XYZ-SNX00000:9443/ once up front gets
its warning out of the way so the redirect lands without interruption.
How each browser surfaces it:
| Browser | What you see | How to continue |
|---|---|---|
| Chrome | "Your connection is not private" — NET::ERR_CERT_AUTHORITY_INVALID |
Advanced → Proceed to <name> (unsafe) |
| Edge | "Your connection isn't private" — NET::ERR_CERT_AUTHORITY_INVALID |
Advanced → Continue to <name> (unsafe) |
| Firefox | "Warning: Potential Security Risk Ahead" — SEC_ERROR_UNKNOWN_ISSUER |
Advanced… → Accept the Risk and Continue |
Firefox keeps its own exception store, so accepting in Chrome or Edge does not cover it, and vice versa.
If localhost forwarding is not working — occasionally it needs
wsl --shutdown and a restart — a rig bound to loopback cannot be reached
from Windows at all. Confirm from inside WSL first:
curl -sk -o /dev/null -w '%{http_code}\n' https://XYZ-SNX00001:5050/controller/ # expect 401A 401 means the Node is serving and the problem is the WSL network layer, not the rig.
Every rig above has a Windows counterpart. Start the bare (no-TLS) rig from
Command Prompt, one launcher per window. Both launchers prefer the repository's
.venv\Scripts\python.exe:
start-registry-bare.bat
start-node1-bare.bat
start-node2-bare.batThese launchers mirror the shell contracts. The node launchers expect an IS-04
Registry on 127.0.0.1 (Query API port 8443, Registration API port 8444),
which start-registry-bare.bat provides with matching defaults. Without a
Registry the Node APIs still start, but their consoles report connection-refused
retries and the Controller cannot assemble a shared two-node resource view.
Each launcher prints the selected Registry address before starting.
The secured rigs have Windows counterparts too. start-node1.bat,
start-node2.bat and start-node3.bat take the same arguments and policy flags
as their shell equivalents, and start-fake-as.bat runs the Authorization
Server, so Configuration C works from Command Prompt too:
start-fake-as.bat
start-registry.bat 2
start-node1.bat XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2
start-node2.bat XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2
start-node3.bat XYZ-SNX00000 9443 XYZ-SNX00000 8444 --rap=2--serial on start-fake-as.bat is repeatable, exactly as in the shell
launcher, so the three-Node rig where the Controller may configure some devices
and not others is available here as well:
start-fake-as.bat --serial=SNX00001 --serial=SNX00002Every token then names those two in its aud, leaving Node 3 discovered through
the registry but inaccessible — see
Secured quick start.
Anything using TLS needs the hosts-file entries described in
Required before any TLS configuration,
in C:\Windows\System32\drivers\etc\hosts, edited as Administrator. The
remaining shell-only launchers are the Configuration A and B node variants
(start-node1-noauth2.sh, start-node1-nomtls.sh).
The launchers default to a registry on 127.0.0.1; NMOS_RDS_HOST overrides
that address and NMOS_RDS_REG_PORT overrides port 8444, with the Query API
port derived as one less than the Registration API port.
The bare rig puts both Nodes on 127.0.0.1, so an activated UDP or RTP stream
sends to a multicast group over the loopback interface. Windows treats that
differently from Linux in two ways, and both matter when reading the consoles.
Sending needs a joiner first. Sending to a multicast group over the loopback
interface fails for as long as nothing on the host has joined that group on
127.0.0.1 — Windows has no route to hand the datagram to. The failure surfaces
as WSAENETUNREACH (10051) on a blocking socket, or as
ERROR_NETWORK_UNREACHABLE (1231) on the overlapped path that asyncio uses:
send error: [WinError 1231] The network location cannot be reached.
The moment a Receiver joins the group on that interface the route exists, sends succeed, and delivery works. So a Sender activated before its Receiver reports a transmission error for the first second or two, then clears itself once the Receiver joins. A Sender that reports 1231 and never clears means no Receiver ever joined the group — check the Receiver console rather than the Sender.
There is no such dependency on a real NIC, where a route always exists. Assigning
the Nodes real interface addresses instead of 127.0.0.1 avoids the startup
error entirely.
Receiving cannot bind the group address. Binding a socket to the multicast
group address is a BSD/Linux idiom — the kernel accepts a class-D address and
narrows delivery to that group. Winsock requires the bind address to be a local
address (a unicast address on an interface, or the wildcard) and rejects a group
address with WSAEADDRNOTAVAIL (10049). The engine therefore binds the wildcard
on Windows and relies on IP_ADD_MEMBERSHIP plus the IS-05 SourceIp filter to
select the traffic; it still binds the group address on Linux.
nmos_registry.py is a standalone IS-04 v1.3 registry — the Registration API
that Nodes POST their resources to, and the Query API (HTTP + WebSocket) that
Controllers read them back from. It removes the need to install a
third-party registry before trying this project.
./start-registry-bare.sh # no TLS
./start-registry.sh 1 # server-authenticated TLS (RAP=1)
./start-registry.sh 2 # mutual TLS (RAP=2)
./start-registry.sh 2 8444 --oauth2 # ... plus OAuth 2.0 on the Query APIThree listeners, defaulting to the ports the Node's --rds* flags already
expect, so a Node needs only --rdsHost:
| Listener | Default port | Node flag |
|---|---|---|
| Registration API | 8447 | --rdsRegistrationPort |
| Query API | 8446 | --rdsQueryPort |
| Query WebSocket | 8448 | target of the subscription ws_href |
The launch scripts use 8444 / 8443 / 8448 instead, matching the defaults the node launchers already pass.
The registry reports its effective Registry Access Policy in the startup banner, so the running compliance mode is visible rather than inferred.
One registry is a single point of failure. --distributed runs 1, 3 or 5
registries over a shared etcd cluster: any of them serves any request, and the
cluster keeps working while members fail.
| Registry/etcd pairs | Failures tolerated |
|---|---|
| 1 | 0 |
| 3 | 1 |
| 5 | 2 |
etcd is authoritative for resource content, mutation ordering and Node liveness. Each registry keeps a complete local view and serves Query entirely from memory — the read path never touches etcd, which is why Query is exactly as fast distributed as standalone. That view is fed by one thing only: the etcd watch. A registry's own writes come back to it the same way a peer's do, so there is nothing to deduplicate and no way for two members to diverge.
Two steps:
pip install -r requirements-etcd.txt # grpcio, protobuf
./install-etcd.sh # etcd v3.6.14 -> ./.etcd/ (checksum verified)The protobuf stubs are committed, exactly as nmos/types/generated/ is, so
there is no codegen step. python -m nmos.etcd.generate is only needed after
changing the vendored protos in nmos/etcd/proto/, and the registry refuses to
start with a clear message if the stubs no longer match them.
Then, one terminal each:
./start-etcd-cluster.sh 3 # the etcd cluster
./start-registry-dist.sh 0 3 # registry member 0
./start-registry-dist.sh 1 3 # registry member 1
./start-registry-dist.sh 2 3 # registry member 2Register against member 0 and read it back from member 1 — the resource, its paging cursors and its subscription events are identical on both.
Member n uses one port block of 10: registration 8444 + 10n, query
8443 + 10n, WebSocket 8448 + 10n.
A registry supervises exactly one etcd process — its own local member. It never starts, stops or reconfigures another member's, and it never changes cluster membership. The rule is stop what you started, never stop what you adopted:
| Situation | Starts it? | Stops it on exit? |
|---|---|---|
| Nothing on the configured port | yes | yes |
| etcd already running, identity matches | no, adopts | no |
| etcd already running, identity differs | refuses | n/a |
--etcdExternal |
no | no |
For production the recommended shape is etcd under systemd with the registry adopting it: a registry restart then costs one reconnect and a preload instead of a member leave/rejoin with the leader election that implies.
Bootstrap and resizing stay explicit. --etcdBootstrap is a one-time flag,
refused if the data directory is non-empty, and an empty data directory is
never taken to mean "make a new cluster" — that is how recovering one dead
member turns into two clusters. Growing 1→3 or 3→5 is a membership change made
against the healthy cluster with etcdctl, one member at a time.
There is no peer channel, no election among registries, no gossip. Every member
is handed the same list and derives member names, the cluster token, and every
URL from it independently — identical input, identical output. All coordination
between registries goes through etcd; the member set is checked against etcd's
own MemberList at startup, and a registry that finds a cluster it was not
configured for refuses to serve rather than joining a split view.
etcd rates its own platforms: Linux amd64/arm64 are Tier 1 ("guaranteed to pass all tests including functional and robustness tests"), while windows/amd64 is Tier 3 ("considered unstable"), unmaintained and not covered by the suites that verify Raft/WAL/fsync durability — the exact guarantees that justify putting the registry's state in etcd.
So no etcd member ever runs on native Windows. There, --distributed
implies --etcdExternal: the registry is a client of a cluster managed
elsewhere, and --etcdBinary/--etcdDataDir/--etcdBootstrap are rejected
rather than ignored. Run the cluster under WSL with start-etcd-cluster.bat and
point the registry at it with start-registry-dist.bat. Nothing needs
installing on the Windows side — etcd_cluster.py status and endpoints are
pure client calls and work from Windows against the WSL cluster.
WSL itself is not a special case and gets no detection: inside WSL
sys.platform is "linux", so a registry there is an ordinary POSIX member
with the full supervisor and a Tier 1 etcd.
One certificate per registry/etcd pair covers all four etcd roles — client
listener, peer listener, outbound peer, and the registry's own client
connection — which is what its dual serverAuth, clientAuth EKU is for.
Generate them with Certificates/genEtcdCerts.sh.
./start-registry.sh 2 8444 \
--distributed \
--registryAdvertisedHost XYZ-SNX10000 \
--registryNeighbour XYZ-SNX10001 --registryNeighbour XYZ-SNX10002 \
--etcdCertificate Certificates/build.0.etcd/pem/ExampleDeviceServer.ABC.SNX10000.etcd.ec.chain.pem \
--etcdKey Certificates/build.0.etcd/key/ExampleDeviceServer.ABC.SNX10000.etcd.ec.key \
--etcdTrustedRootCA Certificates/build.0/ExampleRootCA.ec.pem--etcdCertificateName (default
Example.Company.Device.Etcd.ABC.example.com) is both the gRPC target-name
override and etcd's --client-cert-allowed-hostname / --peer-cert-allowed-hostname.
That restriction is a control, not hardening: the Product CA also signs ordinary
device certificates, and without it any of them would be accepted as an etcd
client and could write to the registry database.
--gcrl is unchanged and continues to apply to the Registration and Query
listeners. etcd's own CRLs are separate, via --etcdClientCrlFile and
--etcdPeerCrlFile.
- Registration: 201/200 with
Location, cascade delete of child resources, referential-integrity and version-regression rejection, heartbeats with garbage collection of silent Nodes (12 s default) and their sub-resources. - Query: pagination with
X-Paging-*andLinkheaders, basic queries including dotted paths into objects and arrays, downgrade validation, and501for the optional RQL and ancestry features. - Subscriptions: WebSocket grains for added / removed / modified / sync
events, filtered subscriptions with the synthetic transition events IS-04
mandates, and
max_update_rate_mscoalescing. - A periodic status line in nmos-cpp's exact format, so logs from the two implementations are directly comparable.
The Node serves its built-in NMOS Controller under /controller/ on --nodeControlPort, once you set --controllerAdminPassword.
The Controller is gated by a password-only login form at /controller/login, checked against --controllerAdminPassword. There is no user name, and this is not HTTP Basic auth — an earlier version of the app used Basic, and a cached Authorization: Basic header is now ignored on the way in and stripped before any request is proxied to a Node (see nmos/controller/auth.py for the rationale: a native browser popup supports neither logout nor error messaging).
Opening any page unauthenticated redirects to the login form. API paths under /controller/api/ answer 401 with:
WWW-Authenticate: Session realm="nmos-controller"
A successful login sets an nmos_controller_session cookie holding <issued_at>.<base64url(hmac_sha256(sha256(password), issued_at))>. Because the signing secret derives from the admin password, changing --controllerAdminPassword invalidates every outstanding session.
For a scripted client, post the password and keep the cookie:
curl -c cookies.txt -X POST -d "password=admin" \
http://127.0.0.1:8080/controller/login # 302 on success, 401 on a bad password
curl -b cookies.txt http://127.0.0.1:8080/controller/api/sendersnmos/agentui/ drives the embedded Controller through a real Chromium, acting only
through the affordances a signed-in operator has, and writes a screenshot-and-text
journal of every step. It exists so the UI's behaviour — particularly its
per-control gating — can be demonstrated and audited rather than described.
If you are an AI agent asked to demo, explain, or walk through the Controller, start with nmos/agentui/FOR-AI-AGENTS.md — how to operate the driver, live or as a scenario, and how to emit a tutorial. Then read nmos/agentui/OPERATING-THE-CONTROLLER.md for the domain rules a demo will otherwise trip over.
It attaches to a node you already started; it never launches one.
start-node*.sh remains the sole launch contract, so which configuration a run
exercises stays your choice. The node's address, control port, scheme, and TLS
trust material are read from its command line; the admin password comes from the
environment and is deliberately not harvested from process state.
Fully optional: the node runtime never imports it, playwright is confined to
nmos/agentui/driver/, and the default test gate never runs it.
cd nmos-reference
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m pip install -r requirements-agentui.txt
# Second step — pip cannot install a browser.
# Fetches a self-contained Chromium (~656 MB) into a repo-local directory.
PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" \
.venv/bin/python -m playwright install chromiumEverything lands in .playwright/: nothing enters the system package database, no
apt or sudo step is required, and removal is rm -rf .playwright. Both
.playwright/ and artifacts/ are gitignored.
Bring up the full rig — registry and two nodes — in three terminals. The driver attaches to what is already running and never starts anything itself:
./start-registry-bare.sh # terminal 1
./start-node1-bare.sh # terminal 2 — SNX00001
./start-node2-bare.sh # terminal 3 — SNX00002# terminal 4, from nmos-reference/
export NMOS_CONTROLLER_ADMIN_PASSWORD=admin # the --controllerAdminPassword value
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright"
.venv/bin/python -m nmos.agentui --listScenarios
.venv/bin/python -m nmos.agentui --scenario attach-and-lookThe full rig runs everything, which is why it is the recommendation. A smaller rig still runs part of the set:
| Rig | Scenarios | Why |
|---|---|---|
| One node, no registry | attach-and-look, inspect-one-sender, selection-guard, blocked-controls, session-lost |
Read-only walks over one node's own resources. The Controller seeds its cache from the local node at startup, which is enough. |
| One node + registry | adds route-one-receiver, privacy-exclusivity |
These activate a route and then watch the status badges. Status travels as BCP-008 monitor resources through IS-04, so without a registry the badges never update and the scenario reports an unconfirmed observation. |
| Two nodes + registry | adds cross-node-reverse, demo-group-route-tb, demo-group-route-hevc, tutorial-jpegxs |
Each routes from a sender on SNX00001 to a receiver on SNX00002, so both must be registered with the same registry for the Controller to see them as one resource set. |
Note that tutorial-jpegxs — the scenario that emits a tutorial — is in the
last row. With one node it cannot find its SNX00002 receiver and stops early.
A run that is short of what it needs does not fail silently: the driver reports
the missing precondition ("Is the node registered with a registry and serving senders?", "reverse-direction buttons will not appear. Start a second node.")
and manifest.json records whether a live status update was genuinely observed
or merely unconfirmed.
On Windows, start the equivalent rig from Command Prompt — see Running the rigs from Windows Command Prompt.
Options mirror nmos_node.py's camelCase style: --scenario, --controlPort
(disambiguate when several nodes serve a UI), --artifactsRoot, --headed,
--stepTimeoutMs, --pinChain.
Artifacts land in artifacts/agentui/<run_id>/ — read journal.md. Alongside it,
manifest.json records the run's own honesty checks: whether any navigation went
unaccounted for, whether a second browser page appeared, whether the driver itself
issued HTTP, whether certificate verification was ever bypassed, and whether a
live status update was genuinely observed or merely unconfirmed.
Scenarios marked makes changes issue real IS-05/IS-11 calls and, by design, perform no teardown — they leave the rig in the state they reached so you can inspect it. Note that an unreleased exclusive-access reservation stays held until the session expires or someone signs out.
For a TLS node the browser is made to trust the certificate by pinning its SPKI
hash, after verifying the chain with nmos/cert_check.py; where the certificate's
own DNS name resolves, the cleaner name-based path is used and no browser flag is
passed at all. There is deliberately no option to disable verification, because a
run with checks switched off looks identical to one where they work.
This implementation follows the Matrox "One Model to Rule them All" design, formalised in the NMOS-MatroxOnly corpus. The model presents two stream topologies — that NMOS controllers typically handle through separate code paths — under a single configuration surface:
- Group of independent streams — multiple Senders, one per essence (e.g., one audio Sender + one video Sender + one data Sender).
- Multiplexed stream — one Sender carrying multiple sub-streams (e.g., a single MPEG2-TS mux containing video + audio + data).
From the user's point of view the configuration model is the same: both shapes expose video 0, audio 0, audio 1, … and are configured through IS-11 active constraints in the same way. The Controller abstracts the underlying transport / streaming implementation. The constraint-set metadata on each Sender / Receiver — format, layer, and layer_compatibility_groups (alongside the natural-grouping role / role-index from BCP-002-01) — are what let one code path drive both topologies.
In practice this means the same IS-11 negotiation handles MPEG2-TS (over RTP, UDP or SRT), RTSP, NDI, AES3/AM824 (over RTP) audio mux containers, and independent RTP senders for the same set of essences. The Controller asks "what's the user's intent for video 0 and audio 0?" once; the per-Sender/Receiver hints decide whether that becomes a mux-sub-stream configuration or a coordinated independent-Sender configuration.
The Node ships with a built-in streaming engine that emulates Sender / Receiver behaviour at the packet level. It lets you validate connectivity, transport setup, encryption, and registry orchestration without real media hardware on either end.
When IS-05 activates a Sender or Receiver, the engine starts (or stops) a flow of structured test packets over the configured transport. Each test packet carries a small fixed-format header that lets the receiving side detect:
- Packet loss — via the per-packet sequence counter
- Late delivery — via the embedded timestamp (>100 ms threshold)
- Source mismatch — the receiver verifies the configured source matches the inbound address
- Length / framing errors — packets that aren't the expected size
- Privacy-encryption integrity — counter + key-version fields exercise the TR-10-13 PEP path end-to-end
Supported transports out of the box:
| Transport | Use |
|---|---|
| UDP multicast / unicast | RTP-style flows, multi-receiver fan-out, MPEG2-TS over UDP |
| SRT | Reliable unicast over the public internet |
| TCP | Reliable unicast |
| USB-over-IP | USB device traffic tunneled over IP per TR-10-14 |
On Windows, multicast flows between Nodes on 127.0.0.1 behave differently from
Linux — see
Windows multicast over loopback.
Together with the embedded capabilities-driven NMOS Controller, the streaming engine supports a multi-Node test fabric across any number of machines: IS-05 activations drive real connections, the Controller runs IS-11 stream-compatibility negotiation against the connected peers, and Senders / Receivers are reconfigured against what the device declares in BCP-004-01/-02 — codec profile / level, sampling, bit-depth, packet-time, transport, channel layout. The TLS, OAuth 2.0, PEP, registry registration, and capability negotiation paths are exercised end-to-end.
AMWA NMOS Interface Specifications (specs.amwa.tv)
| Spec | Role |
|---|---|
| IS-04 Discovery & Registration | Node API, Registry client |
| IS-05 Connection Management | Senders, Receivers, staged/active model |
| IS-10 Authorization | OAuth 2.0 Bearer tokens, JWKS, claims |
| IS-11 Stream Compatibility | Sender / Receiver capability + constraint support via the Matrox CCF framework (caps/) — supported + active constraint sets and parameter constraints; dynamic reconfiguration driven by active constraint sets; per-sub-flow / per-sub-stream configuration for hierarchical mux transports. IS-11 Input / Output resources are not implemented. |
AMWA NMOS Best Current Practices (specs.amwa.tv)
| BCP | Role |
|---|---|
| BCP-002-01 Natural Grouping of NMOS Resources | Grouping Sender / Receiver / Source resources into logical units |
| BCP-002-02 Asset Distinguishing Information | Instance Identifier used by IS-10 audience claims |
| BCP-003-01 Securing Communications with TLS | TLS cipher whitelist, mTLS, version pinning |
| BCP-003-02 Authorization with OAuth 2.0 | Token validation, public-key cache, scope semantics |
| BCP-004-01 Receiver Capabilities | Sender / Receiver capability advertisement |
| BCP-004-02 Receiver Capabilities — Schemas | Capability/constraint JSON schemas |
| BCP-005-03 NMOS With Privacy Encryption | Privacy-encrypted Sender / Receiver wiring |
| BCP-006-01 NMOS With JPEG XS | JPEG XS sender/receiver SDP profile, profile/level/sublevel mapping |
| BCP-006-02 NMOS With H.264 | H.264 (AVC) sender/receiver SDP profile, profile/level mapping |
| BCP-006-03 NMOS With H.265 | H.265 (HEVC) sender/receiver SDP profile, profile/level/tier mapping |
| BCP-007-02 NMOS With USB | USB sender/receiver SDP profile and verification |
| BCP-008-01 Receiver Monitoring | Receiver status delivered through IS-04 — async WebSocket subscriptions to the NMOS registry's /x-nmos/query/v1.3/subscriptions/ (no IS-12 / MS-05-02 dependency) |
| BCP-008-02 Sender Monitoring | Sender status delivered through IS-04 — same async-WebSocket-grain channel; status changes are observable by every controller subscribed to the registry |
VSF Technical Recommendations (vsf.tv)
| TR | Role |
|---|---|
| TR-10-13 Privacy Encryption Protocol | Per-flow AES-CTR encryption, key derivation, RTP adaptation. UDP, SRT, RTSP protocol adaptation also supported. |
| TR-10-14 USB-over-IP | USB protocol adaptation |
Matrox NMOS extensions (NMOS-MatroxOnly)
The Matrox extensions are formalised in the NMOS-MatroxOnly specifications corpus. This implementation covers the subset of that corpus listed below; each entry is integrated with IS-11 and the Matrox CCF so every sub-flow / sub-stream of a hierarchical mux is configured independently by the embedded Controller.
| Extension | Role |
|---|---|
| NMOS With MPEG2-TS | MPEG2-TS (H.222.0) mux containing video + audio + data sub-streams; per-sub-stream IS-11 constraint negotiation |
| NMOS With NDI | NDI mux sender / receiver — Matrox-extended capability set covering the BCP-007-01 surface |
| NMOS With RTSP | RTSP-based receiver with RTP sub-flows; capability-driven RTSP OPTIONS / DESCRIBE / SETUP |
| NMOS With SRT | SRT unicast transport (caller / listener), with PEP encryption hand-off |
| NMOS With USB | USB device transport (USB-over-IP) — Matrox-extended capability set covering the BCP-007-02 surface, with the TR-10-14 protocol adaptation wired through PEP |
| NMOS With AES3 (AM824) | AES3-style audio mux container; per-channel constraint negotiation |
| NMOS With AAC | AAC audio sender / receiver with codec-profile constraints |
| NMOS With H.264 | H.264 (AVC) Sender / Receiver — Matrox-extended capability set covering the BCP-006-02 surface; full profile / level / bitrate negotiation |
| NMOS With H.265 | H.265 (HEVC) Sender / Receiver — Matrox-extended capability set covering the BCP-006-03 surface; profile / level / tier / bitrate negotiation |
| NMOS With Privacy Encryption | PEP integration on every supported transport (RTP, SRT, RTSP, USB) — Matrox-extended capability set covering the BCP-005-03 surface |
| NMOS With OAuth 2.0 | OAuth 2.0 Bearer-token validation flow — Matrox-extended capability set covering the BCP-003-02 / IS-10 surface (JWKS cache lifecycle, claim semantics, scope / x-nmos-* enforcement, public-key rotation) |
| NMOS With Status Reporting | Sender / Receiver monitor resources delivered over IS-04 async WebSocket subscriptions — Matrox-extended capability set covering the BCP-008-01 / BCP-008-02 surface; no IS-12 / MS-05-02 dependency |
| NMOS With IS-11 | Sender / Receiver capability + constraint flow — Matrox-extended capability set covering the IS-11 surface, with hierarchical-mux sub-flow / sub-stream constraints keyed by layer, format, and layer_compatibility_groups. Excludes IS-11 Input / Output resource support. |
| NMOS With Reservation API | Exclusive-acquire / renew / release control surface for protected resources |
| NMOS With Control Plane Security | Control-plane security across the IS-04 Node API and the IS-05 / IS-08 / IS-11 / IS-12 / IS-14 control APIs — Matrox-extended security (IS-10 / BCP-003-01 / BCP-003-02) with TLS 1.2/1.3 (PFS, RSA/ECDSA, CRL), mTLS, and OAuth 2.0 Bearer/JWT validation combinable as mTLS-only / OAuth2-only / mTLS+OAuth2, enforcing the three Node Access Policies (Unrestricted Read-Write, Unrestricted Read-Only, Restricted Read-Write) with fail-closed posture. This specification supersedes NMOS With OAuth 2.0. |
| Capability layer extensions | Constraint sets keyed by layer, format, and layer_compatibility_groups — the basis for hierarchical mux negotiation |
If you're new to NMOS or to the Matrox extensions, two sets of tutorials are recommended starting points:
- AMWA NMOS — concept walk-throughs, architectural overviews, and worked examples are published at specs.amwa.tv/nmos/info/. Start here for the foundational vocabulary (Senders / Receivers / Sources / Flows / Devices / Nodes), the registration + discovery model, and how IS-04 / IS-05 / IS-11 fit together.
- Matrox NMOS Advanced Streaming Architecture (NASA) — Matrox-extension tutorials covering the hierarchical mux model, the IS-11 sub-flow negotiation pipeline, PEP wiring, capability-layer / compat-groups usage, and the Reservation API. Available at github.com/alabou/NMOS-MatroxOnly/tree/main/tutorials.
This repository also generates tutorials from real runs against a live rig, each step carrying a screenshot, the values actually observed, the API calls the run issued, and links to both the specification and the implementing source. Two ship today:
| Tutorial | Teaches | Rig |
|---|---|---|
tutorial-jpegxs |
Activating a video sender and subscribing a receiver over JPEG XS | Two nodes + registry (bare is fine) |
tutorial-security |
How TLS and OAuth 2.0 decide what a Controller may do — the two-stage sign-in, certificate identity, and per-device token scoping | Configuration C, two nodes |
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright"
export NMOS_CONTROLLER_ADMIN_PASSWORD=admin
.venv/bin/python -m nmos.agentui --scenario tutorial-security --tutorialtutorial-security is read-only and changes nothing on the devices. See
nmos/agentui/FOR-AI-AGENTS.md for the driver
itself.
nmos/ — Core NMOS implementation
api/ — HTTP/WS endpoints, TLS context factories, IS-04/05/...
controller/ — Built-in NMOS Controller + outbound OAuth2/registry clients
node/ — Node resources (senders/receivers/sources/flows/devices), config
registry/ — Standalone IS-04 Registration + Query APIs (server side)
specs/ — IS-04 v1.3.3 RAML, JSON schemas and behaviour docs (verbatim)
agentui/ — Agent driver for the Controller UI (real Chromium, journalled runs)
core/ — Surface/step/journal primitives, process scan, TLS pinning
apps/nmos_controller/ — Controller-specific driver: discovery, session, pages, trace join
driver/ — Playwright launcher and Surface implementation
FOR-AI-AGENTS.md, OPERATING-THE-CONTROLLER.md — read these before driving the UI
json/ — Typed JSON serialization engine
types/generated/ — Auto-generated typed wrappers for all NMOS resource types
codegen/ — Go-source parser + generator that produces types/generated/
oauth2/ — Bearer token validation, JWKS cache lifecycle
crypto/ — ExclusiveSession: token-based mutual exclusion for Node Reservation
tasks/ — DispatchGroup wrapping asyncio.TaskGroup
codec/ — Audio/video codec descriptors (H.264, H.265, AAC, JXSV, AES3, …)
enums/, ip/, errors/, uuid/ — Domain primitives
sdp/ — SDP encoding/decoding (Matrox profile)
caps/ — Capability/constraint framework (Matrox CCF)
pep/ — Privacy Encryption Protocol (PEP) helpers
Certificates/build.0/ — Test PKI subset (SNX00000 infrastructure, SNX00001..3
Nodes, both RSA and ECDSA, plus ExampleRootCA-bundle.pem
holding both roots) so TLS and the TLS test suites run
from a clone with nothing outside it
fake-as/ — Test OAuth 2.0 Authorization Server, vendored (see below)
nmos_node.py — Node entry point; parses CLI and starts the server
nmos_registry.py — Registry entry point; Registration + Query + WebSocket listeners
multi_aud_as.py — Runs fake-as/ with a multi-Node token audience, for rigs
where the Controller may configure some devices but not
others (start-fake-as.sh --serial=A --serial=B)
run_server.py — Lightweight wrapper for embedding nmos_node from scripts
demo_controller.py — Standalone demo controller for manual exploration
start-node*.sh — Launch scripts for the three security configurations
start-registry*.sh — Registry launchers (bare = no TLS; the other takes a RAP value)
start-fake-as.sh — Test OAuth 2.0 Authorization Server (vendored; see below)
start-node*-bare.bat — Windows launchers for the bare (registry-only) rigs
start-registry*.bat — Windows registry launchers
requirements.txt — Runtime dependencies
requirements-agentui.txt — Extra dependencies for the agent driver (Playwright)
The TLS + OAuth 2.0 rig needs an Authorization Server. Rather than require
Keycloak and Docker, this repository ships one: fake-as/ holds
ipmx_fake_as.py and ipmx_security_tokens.py, started by
./start-fake-as.sh.
# Full test suite (~4 minutes; 2 900+ tests)
# Paths come from `testpaths` in pyproject.toml — nmos/ plus caps/tests
python3 -m pytest -q
# Per-module
python3 -m pytest -q nmos/oauth2/tests/
python3 -m pytest -q nmos/registry/tests/
python3 -m pytest -q nmos/agentui/tests/
python3 -m pytest -q caps/tests/
python3 -m pytest -q nmos/api/tests/test_tr10_tls.pyThe test markers are documented in pyproject.toml:
- default gate excludes
e2eandslow integrationtests run in-process across multiple modules and remain in the default gatee2e/slowmarkers cover full-protocol scenarios you can opt in to
This repository implements an NMOS Node and a curated set of VSF IPMX / TR-10-x extensions. Its coverage of the broader Matrox specification corpus is bounded by the spec coverage tables above.
NMOS-MatroxOnly/ is the broader Matrox documentation corpus. This Python implementation supports only the subset of that corpus that has been validated end-to-end here. See the NMOS-MatroxOnly repository for the full specification set; the spec coverage tables above list what this implementation exercises.
IS-05 Bulk interface is intentionally not supported. The Node implements the per-Sender / per-Receiver IS-05 single-resource endpoints (/single/...) but does not expose the /bulk/... interface. Bulk operations are out of scope: the Controller drives multi-resource activations as coordinated single-resource calls, which keeps the connection-management state machine uniform across Senders and Receivers and avoids the partial-success semantics of bulk activations. Vendors who need IS-05 Bulk on their own products must add it themselves.
The streaming engine emulates transport, so some TR-10 stream requirements may deliberately not be met. The engine exists to exercise connection management, capability negotiation, encryption, status monitoring and registry orchestration end-to-end — not to be a reference for the media transport itself:
-
IGMP source-specific joins. TR-10 requires IGMPv3 with "the source-specific method". Receivers here join with any-source
IP_ADD_MEMBERSHIPand filter on the IS-05SourceIpin the receive loop instead. For an emulated transport on a single host that is sufficient; a real product should useIP_ADD_SOURCE_MEMBERSHIPso the kernel enforces the source filter. -
RTCP Sender Reports TR-10 requires a Sender to transmit RTCP Sender Reports. The streaming emulation does not.
Those deviations do not affect the control-plane behaviour this project is a reference for.
Apache License 2.0 — © 2025-2026 Alain Bouchard.
This implementation tracks specifications from three bodies:
- The AMWA NMOS Interface Specifications and Best Current Practices maintained by the Advanced Media Workflow Association — the work of the AMWA Networked Media Incubator and contributors.
- The VSF Technical Recommendations (TR-10 family) maintained by the Video Services Forum — the work of the VSF IPMX Task Force.
- The Matrox NMOS Advanced Streaming Architecture (NASA) documented in the NMOS-MatroxOnly specifications corpus.
See each specification for full credits.