Trinnov Altitude Python Library
Async Trinnov Altitude client for long-running integrations (Home Assistant primary target).
Version 2.0
Version 2.x is a clean break from 1.x.
- No compatibility shims
- New lifecycle (
start/wait_synced/stop) - New state model (
client.state) - Optional command ACK handling
Read the migration guide: docs/MIGRATION_V2.md
Installation
pip install trinnov-altitude
Quick Start
import asyncio
from trinnov_altitude.client import TrinnovAltitudeClient
async def main() -> None:
client = TrinnovAltitudeClient(host="192.168.1.90")
try:
await client.start()
await client.wait_synced(timeout=10)
await client.volume_set(-30.0)
await client.mute_on()
print(client.state.volume)
print(client.state.source)
finally:
await client.stop()
asyncio.run(main())
Lifecycle
await client.start()connects, bootstraps, and starts the read loop.await client.wait_synced()waits until welcome + catalogs + current indices are observed.await client.stop()stops listener and disconnects cleanly.
Processor power
Client connection lifecycle and processor power are separate. stop() closes the
client; it does not power down the processor.
await client.power_off()sends the secured shutdown command and waits for its acknowledgement. Concurrent requests share the pending command. Cancelling a requesting task does not cancel acknowledgement handling for an already-sent shutdown;stop()still terminates the client's pending work.await client.wake(shutdown_timeout=60.0)waits for any pending shutdown acknowledgement and, after acknowledged shutdown, for the old connection to close before requesting Wake-on-LAN. The acknowledgement uses the command timeout; the disconnect wait has its own boundedshutdown_timeout.- A successful wake request does not mean boot is complete. Connection,
synchronization, and
runtime.powerprovide subsequent readiness feedback. Wake-on-LAN requires a configured MAC address. runtime.power == PowerState.OFFfollowingpower_off()means shutdown was acknowledged, not that the hardware has already finished powering down. An unexplained connection loss is not evidence of acknowledged shutdown.
Starting with 3.3.12, synchronous power_on() raises CommandRejectedError if a
shutdown acknowledgement is pending or acknowledged shutdown still has an open
connection. Async callers that want to wait through this transition should use
await client.wake(). This prevents the previous connection from being mistaken
for a newly ready processor. power_off() also now requires acknowledgement;
command failures propagate instead of reporting unconfirmed shutdown as success.
Liveness & reconnect
The control connection is a long-lived TCP push session, so a silent read is
ambiguous: a healthy link is quiet whenever nothing is changing, but a dead link
(an idle-killed half-open socket, or a processor whose control thread has wedged
while its TCP stack still ACKs) looks identical. To tell them apart, when the link
has been quiet for heartbeat_interval seconds the client sends a read-only probe
(get_current_state); if no traffic arrives within heartbeat_timeout, the link is
treated as dead and auto_reconnect kicks in. TcpTransport also enables
SO_KEEPALIVE as an OS-level backstop.
client = TrinnovAltitudeClient(
host="192.168.1.90",
heartbeat_interval=20.0, # seconds of quiet before a liveness probe (None disables)
heartbeat_timeout=5.0, # seconds to wait for the probe response before reconnecting
)
State reconciliation
Push messages remain the fast path for state updates. As a backstop for an
individual dropped notification, the client also requests the processor's
current state every reconcile_interval seconds. This schedule is independent
of ordinary push traffic, so unrelated messages cannot leave one cached field
stale indefinitely.
client = TrinnovAltitudeClient(
host="192.168.1.90",
reconcile_interval=30.0, # None disables periodic reconciliation
)
Protocol Semantics
The client parses raw messages first, then normalizes them into canonical state events. This keeps protocol quirks isolated and keeps the state reducer deterministic.
- Canonical identity:
CURRENT_PRESET <n>CURRENT_PROFILE <n>or index-onlyPROFILE <n>DECODER ... UPMIXER <mode>
- Optional catalogs:
- Presets via
LABELS_CLEAR+LABEL <n>: <name> - Sources via
PROFILES_CLEAR+PROFILE <n>: <name>
- Presets via
- Quirk profiles:
altitude_ciis selected whenIDENTSincludesaltitude_ciMETA_PRESET_LOADED <n>requests authoritative preset/source readbacks; it does not set either identity directly
Catalog messages may arrive late, be refreshed, or be absent. Consumers should not assume labels are always present.
Text normalization
The parser matches ASCII protocol keywords without regard to case and preserves
captured labels and unfamiliar values. The transport removes only the line ending.
Known upmixer values are normalized centrally, including case, surrounding
whitespace, and underscore/space spelling. Both UPMIXER <mode> and an exact
known bare mode produce the same configured state.upmixer. DECODER messages
update only state.active_upmixer; these are distinct facts.
An unfamiliar prefixed mode remains visible verbatim (apart from surrounding
whitespace); it is not guessed from a partial match. For example, dolby dolby
is retained but is not treated as confirmation of dolby. A bare unrecognized
line remains unknown. Recognized bare replies do not increment unknown-message
counters. Adapters can use normalizer.normalize_upmixer_mode() rather than
maintaining their own spelling rules.
Command completion and retries
TCP delivery, command acceptance, and observed device state are separate events. The public operations intentionally make different guarantees:
| Operations | Return means | Automatic repetition |
|---|---|---|
preset_set, upmixer_set |
Requested selector state observed, or an exception | Setting sent once; only readback queries repeat |
source_set, source_set_by_name |
Requested source observed, or an exception | Source command and readback can repeat, preserving existing source-selection behavior |
volume_set |
Setting and refresh query sent | Neither repeats; subsequent feedback updates state |
| Relative volume, toggles, remapping, other simple setters | Command sent | Never automatically replayed |
power_off |
Shutdown acknowledged | Concurrent callers share one shutdown request |
power_on, wake |
Wake requested (or already connected and synced) | Completion remains explicit in runtime lifecycle state |
command(..., wait_for_ack=True) |
An ACK was received, or an exception | No automatic retry; ACK is not selector completion |
Selector confirmation has a finite selector_convergence_timeout (default 5s)
and uses selector_convergence_interval (default 0.25s) between queries. The
confirmation deadline includes query I/O, and cancellation stops its polling.
A timeout raises CommandConvergenceTimeoutError; unrelated status pushes or
an ACK cannot confirm a different selector value. Preset/source setters may
return immediately when the requested value is already recorded.
upmixer_set now waits for confirmation instead of returning after sending a
single query. Callers should handle CommandConvergenceTimeoutError when the
processor never reports the requested mode. Existing command families retain
their documented semantics; toggles and relative changes must not be retried as
though they were absolute setters.
Events
def on_event(event, message):
if event == "connected":
...
elif event == "disconnected":
...
elif event == "received_message":
...
client.register_callback(on_event)
Callback exceptions are isolated and logged (they do not crash the listener).
HA Adapter
Use trinnov_altitude.adapter.AltitudeStateAdapter to convert mutable runtime state into immutable snapshots plus typed deltas/events:
snapshot: stable full-state view for coordinator datadeltas: field-level changes since previous snapshotevents: integration-friendly event stream (volume, mute, source, preset, etc.)
You can wire this directly through the client:
from trinnov_altitude.adapter import AltitudeStateAdapter
adapter = AltitudeStateAdapter()
def on_update(snapshot, deltas, events):
...
handle = client.register_adapter_callback(adapter, on_update)
# later: client.deregister_adapter_callback(handle)
For Home Assistant coordinator/event-bus integration, use trinnov_altitude.ha_bridge:
coordinator_payload(snapshot)to_ha_events(events)build_bridge_update(snapshot, deltas, events)
Command ACKs
You can use fire-and-forget commands (default) or explicit ACK waiting:
await client.volume_set(-20.0)
await client.command("volume -20", wait_for_ack=True, ack_timeout=2.0)
Development
uv sync --group dev
uv run ruff check .
uv run ruff format --check .
uv run ty check trinnov_altitude
uv run pytest -v
Or use task wrappers:
task dev
task check
Real Device Integration Tests (Read-Only)
The test suite includes a manual, read-only integration tier for validating behavior against real hardware.
- Marker:
integration_real - Opt-in gate:
TRINNOV_ITEST=1 - Target host:
TRINNOV_HOST=<ip-or-hostname> - Optional port override:
TRINNOV_PORT=44100 - If the device is offline/unreachable, tests are skipped.
These tests intentionally avoid mutating commands (no power/preset/source/volume state changes).
TRINNOV_ITEST=1 TRINNOV_HOST=192.168.30.3 task test:integration-real
Pyx (optional)
Pyx is optional in this repo. You can keep publishing to PyPI/TestPyPI only.
- Install via Pyx: authenticate
uvwithPYX_API_KEYand configure your Pyx index URL inuv(uv add --index .../uv sync). - Publish to Pyx: run the
Releaseworkflow manually withtarget=pyxafter setting repository secretsPYX_API_KEYandPYX_PUBLISH_URL. - No dual-publish requirement: use Pyx when you need private/internal package distribution or policy control.
Release
- Merge conventional-commit changes to
master. - Wait for the
release-pleaseworkflow to open/update a release PR. - Review and merge the release PR (this updates
CHANGELOG.mdand__version__). - Release Please creates the GitHub Release and tag.
- The
Releaseworkflow publishes artifacts to PyPI automatically for published releases. - For TestPyPI or Pyx-only publishing, run
Releasemanually withworkflow_dispatch.
Maintenance
- Migration guide: docs/MIGRATION_V2.md
- Maintainer runbook: docs/MAINTAINERS.md
- Protocol reference used for implementation: docs/Altitude Protocol.pdf (v1.15, 2019-04-19)
Wake-on-LAN network settings
The optional keyword-only constructor arguments wol_host, wol_port,
wol_interface, and wol_family apply to both power_on() and await wake().
Defaults preserve IPv4 broadcast to 255.255.255.255:9 with the OS-selected
outgoing interface. The control connection's host and port are independent.
import socket
client = TrinnovAltitudeClient(
host="192.168.20.10",
mac="00:11:22:33:44:55",
wol_host="192.168.20.255",
wol_port=9,
wol_interface="192.168.10.2",
wol_family=socket.AF_INET,
)
wol_interface is a local IP address in the application's network namespace,
not an interface name or the receiver's address. Use socket.AF_INET6 for an IPv6 destination. The
network still needs to permit or relay the packet across VLANs; successful UDP
sending does not confirm delivery or readiness. Local socket errors propagate
to the caller without marking the device as waking.
Release files for trinnov-altitude 3.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| trinnov_altitude-3.4.1.tar.gz | 269.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| trinnov_altitude-3.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 306.2 kB
Release files / trinnov_altitude-3.4.1.tar.gz
| Download URL | trinnov_altitude-3.4.1.tar.gz |
|---|---|
| Size | 269.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
628bd01d4e633b8d7757a234e92f2f279d249960897abc22c1b706f200b4d949
|
|
BLAKE2b-256 checksum How to use checksums |
0fb2652ebbce018035dd10822ec2c44019e2b40c27a099a6a4137da4ae2b28f7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / trinnov_altitude-3.4.1-py3-none-any.whl
| Download URL | trinnov_altitude-3.4.1-py3-none-any.whl |
|---|---|
| Size | 36.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c5088adc90aa338cb87f1f213d0559aa79d3d3c4a01822d88ba2d2a560d070e
|
|
BLAKE2b-256 checksum How to use checksums |
7b58fd66f6a8b7d6d8648667f616bcd442034058cc589d7996ca51b72d37b583
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log