lametric-py

Async Python client for LaMetric devices and the LaMetric cloud API. Wraps the local device API, notification models, and the LaMetric Streaming Protocol (LMSP).

Python 3.14+ asyncio aiohttp typed MIT

Local Device API

Control display, audio, Bluetooth, apps, and notifications over HTTPS.

Notifications

Text, goal, and chart frames with built-in or custom sounds.

LMSP Streaming

Push RGB888 frames to LaMetric SKY over UDP via the streaming protocol.

Cloud API

Fetch your user profile and registered devices from developer.lametric.com.

Installation

The package is installed from source using uv:

git clone <repository-url>
cd lametric-py
uv sync

Or with pip:

pip install -e .

Local Device Client

LaMetricDevice connects to the local HTTPS API on port 4343. Use it as an async context manager — it manages the underlying aiohttp session for you.

import asyncio
from lametric import LaMetricDevice

async def main() -> None:
    async with LaMetricDevice(host="192.168.1.42", api_key="device-api-key") as device:
        state = await device.state
        print(state.display.brightness)

asyncio.run(main())

The device IP and API key are visible in the LaMetric app under Settings → Wi-Fi and Settings → Developer → API Key.

Display & Audio

Basic settings

await device.set_audio(25)
await device.set_display(on=True, brightness=75)
await device.set_bluetooth(active=True, name="Office SKY")

Brightness mode

from lametric import BrightnessMode

await device.set_display(brightness_mode=BrightnessMode.AUTO)

Screensaver

from datetime import datetime
from lametric import ScreensaverConfig, ScreensaverConfigParams, ScreensaverModes

await device.set_display(
    screensaver_config=ScreensaverConfig(
        enabled=True,
        mode=ScreensaverModes.TIME_BASED,
        mode_params=ScreensaverConfigParams(
            enabled=True,
            start_time_gmt=datetime(2000, 1, 1, 22, 0),
            end_time_gmt=datetime(2000, 1, 1, 7, 0),
        ),
    )
)

App Management

apps = await device.installed_apps                    # dict[str, App]
weather = await device.get_installed_app("com.lametric.weather")

await device.activate_next_app()
await device.activate_previous_app()
await device.activate_widget(app_id="com.example.app", widget_id="main")

Widget actions

await device.activate_action(
    app_id="com.example.app",
    widget_id="main",
    action_id="my_action",
    action_parameters={"key": "value"},
    visible=True,
)

Notifications

Send and dismiss

from lametric import (
    BuiltinSound, Notification, NotificationData,
    NotificationPriority, NotificationSound, SimpleFrame,
)

notification = Notification(
    priority=NotificationPriority.INFO,
    model=NotificationData(
        frames=[SimpleFrame(text="Build successful")],
        sound=BuiltinSound(id=NotificationSound.POSITIVE1),
    ),
)

notification_id = await device.send_notification(notification)
await device.dismiss_notification(notification_id)

# Dismiss helpers
await device.dismiss_current_notification()
await device.dismiss_all_notifications()

Read the queue

queued  = await device.notifications          # list[Notification]
current = await device.current_notification  # Notification | None

Frame types

from lametric import GoalFrame, GoalFrameData, SpikeChartFrame

goal_frame = GoalFrame(
    icon="i1234",
    goal_data=GoalFrameData(start=0, current=42, end=100, unit="%"),
)

chart_frame = SpikeChartFrame(chart_data=[1, 3, 5, 3, 1, 0, 2])

Sound types

BuiltinSound infers its category automatically from the sound identifier:

from lametric import AlarmSound, BuiltinSound, NotificationSound

alarm   = BuiltinSound(id=AlarmSound.ALARM1)             # category → ALARMS
notify  = BuiltinSound(id=NotificationSound.CASH)        # category → NOTIFICATIONS

WebSound streams audio from a URL with an optional built-in fallback:

from lametric import WebSound

sound = WebSound(
    url="https://example.com/alert.mp3",
    fallback=BuiltinSound(id=NotificationSound.NOTIFICATION),
)

LMSP Streaming

LaMetric SKY supports the LaMetric Streaming Protocol (LMSP) over UDP. The flow is:

  1. Read stream metadata — await device.stream_state
  2. Start a stream — await device.start_stream(config)
  3. Send RGB888 frames — await device.send_stream_data(session_id, frame)
  4. Stop the stream — await device.stop_stream()

Start a Stream

from lametric import (
    CanvasFillType, CanvasPostProcess,
    CanvasPostProcessType, CanvasRenderMode, StreamConfig,
)

config = StreamConfig(
    fill_type=CanvasFillType.SCALE,
    render_mode=CanvasRenderMode.PIXEL,
    post_process=CanvasPostProcess(type=CanvasPostProcessType.NONE),
)

session_id = await device.start_stream(config)

Post-processing effects

from lametric import (
    CanvasFadingPixelsEffectParameters, CanvasPostProcessEffectType,
    CanvasPostProcessParameters,
)

config = StreamConfig(
    fill_type=CanvasFillType.SCALE,
    render_mode=CanvasRenderMode.PIXEL,
    post_process=CanvasPostProcess(
        type=CanvasPostProcessType.EFFECT,
        params=CanvasPostProcessParameters(
            effect_type=CanvasPostProcessEffectType.FADING_PIXELS,
            effect_params=CanvasFadingPixelsEffectParameters(
                smooth=True, pixel_fill=0.8, fade_speed=0.5, pixel_base=0.2,
            ),
        ),
    ),
)

Send Frames

send_stream_data builds a complete LMSP UDP packet and delivers it to the device. The packet layout (little-endian) is:

Field Size Notes
Protocol name 4 bytes From stream state
Version 2 bytes From stream state
Session ID 16 bytes Returned by start_stream
Content encoding 1 byte 0x00 = raw RGB888
Reserved 1 byte
Canvas area count 1 byte Always 1 (full canvas)
Reserved 1 byte
X / Y / Width / Height 2 bytes each Canvas dimensions
Data length 2 bytes
Pixel data variable RGB888 bytes
stream_state = await device.stream_state
width  = stream_state.canvas.pixel.size.width
height = stream_state.canvas.pixel.size.height

frame = bytes([255, 0, 0] * (width * height))  # solid red
await device.send_stream_data(session_id, frame)
UDP delivery is best-effort. Send frames in a loop at the desired frame rate for smooth animation. Stop the stream with await device.stop_stream() when done.

Cloud Client

LaMetricCloud authenticates with developer.lametric.com using a bearer token.

import asyncio
from lametric import LaMetricCloud

async def main() -> None:
    async with LaMetricCloud(token="developer-token") as cloud:
        user    = await cloud.current_user  # CloudUser
        devices = await cloud.devices       # list[CloudDevice]
        print(user.name)
        print([d.name for d in devices])

asyncio.run(main())

Obtain a developer token at developer.lametric.com.

Errors

Both clients raise the same exception hierarchy:

Exception When raised
LaMetricApiError Generic HTTP error from the API
LaMetricAuthenticationError 401 / 403 — invalid credentials or token
LaMetricConnectionError Timeout, DNS failure, or transport error
LaMetricUnsupportedError 404 / 405 — endpoint not supported on this firmware

Development

Setup

git clone <repository-url>
cd lametric-py
uv sync --dev
pre-commit install

Quality gates

uv run ruff check .
uv run ruff format --check .
uv run mypy .
uv run pytest --cov=src --cov-report=term-missing

Project layout

Path Purpose
src/lametric/device.py Local device client
src/lametric/cloud.py Cloud API client
src/lametric/device_notifications.py Notification payload models
src/lametric/device_states.py Device & stream response models
src/lametric/device_apps.py Installed app models
src/lametric/device_configs.py Request payload models (display, screensaver, stream)
src/lametric/const.py Enums and protocol constants
src/lametric/exceptions.py Custom exception hierarchy
tests/ Unit tests
tests/runtime_test.py Live integration test (requires a real device)

Runtime integration test

Exercises every public API method against a real device and restores write-only state after each test:

python tests/runtime_test.py --host <ip> --api-key <key>