Publish LumaOps source

This commit is contained in:
LumaOps release export
2026-09-03 01:18:36 +02:00
commit 7f1c0e5f71
2363 changed files with 501543 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from lumaops_backend.config import Settings
from lumaops_backend.main import create_app
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'lumaops.db').as_posix()}",
log_level="ERROR",
)
@pytest.fixture
def client(settings: Settings) -> Iterator[TestClient]:
with TestClient(create_app(settings)) as test_client:
yield test_client
+403
View File
@@ -0,0 +1,403 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
from lumaops_backend.main import create_app
from lumaops_backend.services.commands import CommandService
def test_direct_mode_without_explicit_color_preserves_last_nonzero_color() -> None:
color = RGBColor(red=86, green=96, blue=255)
effective = CommandService._effective_state(
{
"desired_state": DeviceState(power=True, colors=[color]).model_dump(mode="json"),
"modes": [{"index": 0, "name": "Direct"}],
},
DeviceState(power=True, mode_index=0),
)
assert effective.colors == [color]
assert effective.mode_index == 0
def test_main_device_command_flow(client: TestClient) -> None:
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
assert discovery.json()["found_count"] == 2
devices_response = client.get("/api/v1/devices")
assert devices_response.status_code == 200
devices = devices_response.json()["items"]
assert len(devices) == 2
device = next(item for item in devices if item["fingerprint"] == "mock-mainboard")
request = {"state": {"power": True, "colors": [{"red": 20, "green": 40, "blue": 60}]}}
command = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert command.status_code == 200
assert command.json()["status"] == "succeeded"
duplicate = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert duplicate.json()["id"] == command.json()["id"]
detail = client.get(f"/api/v1/devices/{device['id']}").json()
assert detail["state"]["colors"][0] == {"red": 20, "green": 40, "blue": 60}
resized = client.put(
f"/api/v1/devices/{device['id']}/zones/1",
json={"led_count": 24},
)
assert resized.status_code == 200
assert resized.json()["zones"][1]["led_count"] == 24
assert resized.json()["led_count"] == 32
zone_color = {"red": 10, "green": 80, "blue": 160}
zone_command = client.post(
f"/api/v1/devices/{device['id']}/zones/1/state",
json={"state": {"colors": [zone_color]}},
)
assert zone_command.status_code == 200
assert zone_command.json()["zone_index"] == 1
assert zone_command.json()["led_count"] == 120
assert client.get(f"/api/v1/devices/{device['id']}").json()["zones"][1]["led_count"] == 120
commands = client.get("/api/v1/commands").json()
audit = client.get("/api/v1/audit").json()
assert commands["total"] == 2
assert audit["total"] >= 1
assert any(item["action"] == "device.resize_zone" for item in audit["items"])
def test_desired_state_survives_connector_restart_and_power_cycle(client: TestClient) -> None:
client.post("/api/v1/discovery")
device = next(
item
for item in client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
target = f"/api/v1/devices/{device['id']}"
color = {"red": 81, "green": 109, "blue": 245}
applied = client.post(
f"{target}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
detail = client.get(target).json()
assert detail["desired_state"]["colors"][0] == color
connector = client.app.state.context.registry.get("openrgb-mock")
assert isinstance(connector, MockOpenRGBAdapter)
connector._devices["mock-mainboard"].state = DeviceState( # noqa: SLF001
power=False,
brightness=72,
colors=[RGBColor(red=0, green=0, blue=0)] * 12,
mode="Direct",
mode_index=0,
speed=5,
)
commands_before = client.get("/api/v1/commands").json()["total"]
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
restored = client.get(target).json()
assert restored["state"]["power"] is True
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands_after = client.get("/api/v1/commands").json()
assert commands_after["total"] == commands_before + 1
assert commands_after["items"][0]["actor"] == "system-restore"
assert client.post("/api/v1/discovery").status_code == 200
assert client.get("/api/v1/commands").json()["total"] == commands_after["total"]
assert client.post(f"{target}/state", json={"state": {"power": False}}).status_code == 200
powered_off = client.get(target).json()
assert powered_off["state"]["power"] is False
assert powered_off["desired_state"]["colors"]
assert all(entry == color for entry in powered_off["desired_state"]["colors"])
assert client.post(f"{target}/state", json={"state": {"power": True}}).status_code == 200
powered_on = client.get(target).json()
assert powered_on["state"]["power"] is True
assert powered_on["state"]["colors"]
assert all(entry == color for entry in powered_on["state"]["colors"])
def test_explicit_colorless_effect_is_not_replaced_by_power_restore(
client: TestClient,
) -> None:
device = client.get("/api/v1/devices").json()["items"][0]
client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "colors": [{"red": 24, "green": 48, "blue": 96}]}},
)
effect = client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "mode_index": 2}},
)
assert effect.status_code == 200
assert effect.json()["state"]["mode_index"] == 2
def test_startup_reconciles_desired_state_once_before_ready(settings) -> None:
color = {"red": 24, "green": 96, "blue": 192}
device_id = ""
with TestClient(create_app(settings)) as first_client:
device = next(
item
for item in first_client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
device_id = device["id"]
applied = first_client.post(
f"/api/v1/devices/{device_id}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
with TestClient(create_app(settings)) as restarted_client:
assert restarted_client.get("/health/ready").json() == {"status": "ready"}
restored = restarted_client.get(f"/api/v1/devices/{device_id}").json()
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands = restarted_client.get("/api/v1/commands?limit=100").json()["items"]
restores = [
command
for command in commands
if command["actor"] == "system-restore" and command["target_id"] == device_id
]
assert len(restores) == 1
assert restores[0]["status"] == "succeeded"
def test_rooms_groups_and_scenes(client: TestClient) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Werkplek", "sort_order": 1})
assert room.status_code == 201
first = devices[0]
assert (
client.patch(
f"/api/v1/devices/{first['id']}", json={"room_id": room.json()["id"]}
).status_code
== 200
)
group = client.post(
"/api/v1/groups",
json={"name": "Bureau", "device_ids": [item["id"] for item in devices]},
)
assert group.status_code == 201
tagged = client.patch(f"/api/v1/devices/{devices[1]['id']}", json={"tags": ["bureau", "rgb"]})
assert tagged.status_code == 200
assert tagged.json()["tags"] == ["bureau", "rgb"]
dynamic_group = client.post(
"/api/v1/groups",
json={"name": "Met tag", "dynamic_query": {"tags": ["bureau"], "match": "all"}},
)
assert dynamic_group.status_code == 201
assert [item["id"] for item in dynamic_group.json()["devices"]] == [devices[1]["id"]]
assert client.get("/api/v1/tags").json()[0]["device_count"] == 1
scene = client.post(
"/api/v1/scenes",
json={
"name": "Focus",
"favorite": True,
"items": [
{
"target_type": "group",
"target_id": group.json()["id"],
"state": {"power": True, "colors": [{"red": 42, "green": 80, "blue": 180}]},
}
],
},
)
assert scene.status_code == 201
preview = client.get(f"/api/v1/scenes/{scene.json()['id']}/preview").json()
assert preview["device_count"] == 2
applied = client.post(
f"/api/v1/scenes/{scene.json()['id']}/apply", json={"rollback_on_failure": True}
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
duplicate = client.post(f"/api/v1/scenes/{scene.json()['id']}/duplicate", json={})
assert duplicate.status_code == 201
assert duplicate.json()["name"] == "Focus (kopie)"
assert len(duplicate.json()["items"]) == 1
exported = client.get(f"/api/v1/scenes/{scene.json()['id']}/export")
assert exported.status_code == 200
assert exported.json()["format"] == "lumaops-scene-v1"
imported = client.post("/api/v1/scenes/import", json=exported.json())
assert imported.status_code == 201
assert imported.json()["name"] == "Focus"
assert client.get("/api/v1/scenes").json()["total"] == 3
def test_physical_dram_family_groups_modules_and_supports_group_commands(
client: TestClient,
) -> None:
devices = client.get("/api/v1/devices").json()["items"]
device_ids = [device["id"] for device in devices]
with client.app.state.context.database.connection() as conn:
conn.execute(
"UPDATE devices SET device_type='dram', name='Corsair Vengeance RGB Pro SL DDR4', "
"vendor='Corsair', model='Corsair DRAM RGB Device', state_json=(SELECT state_json "
"FROM devices ORDER BY controller_index LIMIT 1) WHERE id IN (?, ?)",
tuple(device_ids),
)
groups = client.get("/api/v1/device-groups")
assert groups.status_code == 200
assert len(groups.json()) == 1
group = groups.json()[0]
assert group["device_type"] == "dram"
assert group["module_count"] == 2
assert group["mixed"] is False
assert [device["controller_index"] for device in group["devices"]] == [0, 1]
color = {"red": 34, "green": 102, "blue": 204}
applied = client.post(
f"/api/v1/device-groups/{group['id']}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
assert [result["status"] for result in applied.json()["results"]] == [
"succeeded",
"succeeded",
]
for device_id in device_ids:
detail = client.get(f"/api/v1/devices/{device_id}").json()
assert detail["desired_state"]["colors"][0] == color
def test_setup_health_backup_and_diagnostics(client: TestClient) -> None:
setup = client.post("/api/v1/setup/inspect")
assert setup.status_code == 200
assert "storage" in setup.json()
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_location_confirmed": True},
)
assert complete.json()["completed"] is True
assert client.get("/health/live").status_code == 200
assert client.get("/health/ready").status_code == 200
health = client.get("/api/v1/health")
assert health.status_code == 200
backup = client.post("/api/v1/backups")
assert backup.status_code == 201
assert backup.json()["name"].endswith(".db")
diagnostic = client.get("/api/v1/diagnostics/export")
assert diagnostic.status_code == 200
assert diagnostic.content.startswith(b"PK")
def test_setup_complete_accepts_legacy_backup_field(client: TestClient) -> None:
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_confirmed": True},
)
assert complete.status_code == 200
assert complete.json()["completed"] is True
def test_resource_lifecycle_group_capabilities_and_automation_history(
client: TestClient,
) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Studio", "description": "Boven"}).json()
updated_room = client.put(
f"/api/v1/rooms/{room['id']}",
json={"name": "Werkstudio", "description": "Bovenverdieping"},
)
assert updated_room.status_code == 200
assert updated_room.json()["description"] == "Bovenverdieping"
client.patch(f"/api/v1/devices/{devices[0]['id']}", json={"room_id": room["id"]})
assert client.delete(f"/api/v1/rooms/{room['id']}").status_code == 204
assert client.get(f"/api/v1/devices/{devices[0]['id']}").json()["room_id"] is None
group = client.post(
"/api/v1/groups",
json={"name": "Alles", "device_ids": [device["id"] for device in devices]},
).json()
group_result = client.post(
f"/api/v1/groups/{group['id']}/state",
json={"state": {"speed": 3}},
)
assert group_result.status_code == 200
assert {item["status"] for item in group_result.json()["results"]} == {
"succeeded",
"skipped",
}
scene = client.post("/api/v1/scenes", json={"name": "Leeg", "items": []}).json()
automation = client.post(
"/api/v1/automations",
json={
"name": "Avond",
"description": "Dagelijkse rustige scène",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0, 2, 4]},
"actions": [{"type": "scene", "scene_id": scene["id"]}],
"timezone": "Europe/Brussels",
"cooldown_seconds": 0,
"conflict_key": "woonkamer",
},
)
assert automation.status_code == 201
assert automation.json()["description"] == "Dagelijkse rustige scène"
run = client.post(f"/api/v1/automations/{automation.json()['id']}/run")
assert run.status_code == 200
assert run.json()["status"] == "succeeded"
history = client.get(f"/api/v1/automations/{automation.json()['id']}/runs").json()
assert history["total"] == 1
assert history["items"][0]["status"] == "succeeded"
audit = client.get("/api/v1/audit?limit=500").json()["items"]
assert any(item["action"] == "api.create_room" for item in audit)
assert any(item["action"] == "api.create_automation" for item in audit)
def test_automation_requests_reject_invalid_schedules_and_actions(client: TestClient) -> None:
base = {
"name": "Ongeldig",
"description": "Validatiecontrole",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0]},
"actions": [{"type": "scene", "scene_id": "scene-id"}],
"timezone": "Europe/Brussels",
}
invalid_time = client.post(
"/api/v1/automations",
json={**base, "trigger": {"type": "time", "at": "25:90", "weekdays": [0]}},
)
assert invalid_time.status_code == 422
assert invalid_time.json()["error"]["code"] == "validation_error"
missing_scene = client.post(
"/api/v1/automations",
json={**base, "actions": [{"type": "scene", "scene_id": None}]},
)
assert missing_scene.status_code == 422
invalid_timezone = client.post(
"/api/v1/automations",
json={**base, "timezone": "Mars/Olympus_Mons"},
)
assert invalid_timezone.status_code == 422
@@ -0,0 +1,25 @@
from __future__ import annotations
import pytest
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
@pytest.mark.asyncio
async def test_mock_connector_contract() -> None:
connector = MockOpenRGBAdapter()
await connector.start()
health = await connector.test_connection()
assert health.connected is True
devices = await connector.discover()
assert devices
device = devices[0]
before = await connector.get_state(device.external_id)
assert before.colors
after = await connector.set_state(
device.external_id,
DeviceState(power=True, colors=[RGBColor(red=10, green=20, blue=30)]),
)
assert after.colors == [RGBColor(red=10, green=20, blue=30)]
await connector.stop()
@@ -0,0 +1,95 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from lumaops_backend.config import Settings
from lumaops_backend.database import Database
from lumaops_backend.logging_config import JsonFormatter
from lumaops_backend.main import create_app
from lumaops_backend.secrets import SecretStore
def test_production_lan_bind_requires_authentication() -> None:
with pytest.raises(ValidationError, match="niet-lokale productiebind"):
Settings(auth_enabled=False)
def test_authentication_rejects_placeholder_token() -> None:
with pytest.raises(ValidationError, match="minstens 32 tekens"):
Settings(LUMAOPS_ADMIN_TOKEN="replace-with-a-long-random-token") # noqa: S106
def test_migration_is_repeatable(settings: Settings) -> None:
database = Database(settings)
database.initialize()
database.initialize()
with database.connection() as conn:
versions = conn.execute("SELECT version FROM schema_migrations").fetchall()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert [row["version"] for row in versions] == [
"0001_initial",
"0002_automation_descriptions",
"0003_device_desired_state",
"0004_device_classification",
]
assert foreign_keys == 1
assert journal_mode == "wal"
def test_secrets_are_encrypted_and_key_is_persistent(settings: Settings) -> None:
first = SecretStore(settings)
ciphertext = first.encrypt("very-secret-token")
assert b"very-secret-token" not in ciphertext
second = SecretStore(settings)
assert second.decrypt(ciphertext) == "very-secret-token"
assert (settings.config_dir / "secret.key").exists()
def test_json_logger_redacts_secret_fields() -> None:
formatter = JsonFormatter()
record = logging.LogRecord(
"test",
logging.INFO,
__file__,
1,
"payload %s",
({"api_key": "secret", "device": "ok"},),
None,
)
rendered = formatter.format(record)
assert "[REDACTED]" in rendered
assert "secret" not in rendered
def test_auth_cookie_requires_csrf(tmp_path: Path) -> None:
settings = Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=True,
LUMAOPS_ADMIN_TOKEN="correct-horse-battery-staple-test-token", # noqa: S106 - test-only token
secure_cookies=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'auth.db').as_posix()}",
log_level="ERROR",
)
with TestClient(create_app(settings)) as client:
assert client.get("/api/v1/system").status_code == 401
login = client.post(
"/api/v1/auth/login", json={"token": "correct-horse-battery-staple-test-token"}
)
assert login.status_code == 200
assert client.get("/api/v1/system").status_code == 200
without_csrf = client.post("/api/v1/discovery")
assert without_csrf.status_code == 403
csrf = login.json()["csrf_token"]
assert client.post("/api/v1/discovery", headers={"X-CSRF-Token": csrf}).status_code == 200
@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
import struct
from pathlib import Path
import pytest
from lumaops_backend.config import Settings
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.openrgb.adapter import OpenRGBAdapter, expand_colors
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
ModeFlag,
PacketId,
pack_color,
pack_header,
pack_string,
parse_header,
)
def controller_packet(
*,
active_mode: int = 0,
controller_colors: list[RGBColor] | None = None,
static_color: RGBColor | None = None,
) -> bytes:
color = static_color or RGBColor(red=16, green=32, blue=48)
led_colors = controller_colors or [RGBColor(red=16, green=32, blue=48)] * 2
direct_mode = (
pack_string("Direct")
+ struct.pack("<iI", 0, int(ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
static_mode = (
pack_string("Static")
+ struct.pack("<iI", 7, int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_MODE_SPECIFIC_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
custom_mode = (
pack_string("Custom")
+ struct.pack("<iI", 1, int(ModeFlag.HAS_PER_LED_COLOR | ModeFlag.AUTOMATIC_SAVE))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 0, 120, 2, 0)
+ struct.pack("<H", 0)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("SDK Fixture")
+ pack_string("LumaOps")
+ pack_string("Integration test")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 3)
+ struct.pack("<i", active_mode)
+ direct_mode
+ custom_mode
+ static_mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ b"".join(pack_color(item) for item in led_colors)
+ struct.pack("<H", 0)
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_uniform_color_state_survives_a_changed_led_count() -> None:
color = RGBColor(red=86, green=96, blue=255)
assert expand_colors([color] * 6, 23) == [color] * 23
@pytest.mark.asyncio
async def test_real_adapter_handshake_inventory_and_write(tmp_path: Path) -> None:
seen: list[tuple[int, bytes]] = []
update_received = asyncio.Event()
effect_received = asyncio.Event()
resize_received = asyncio.Event()
active_mode = 0
fixture_colors = [RGBColor(red=16, green=32, blue=48)] * 2
fixture_static_color = RGBColor(red=16, green=32, blue=48)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
nonlocal active_mode, fixture_colors, fixture_static_color
try:
while True:
header = parse_header(await reader.readexactly(HEADER.size))
payload = await reader.readexactly(header.payload_size)
seen.append((header.packet_id, payload))
if header.packet_id == PacketId.UPDATE_LEDS:
count = struct.unpack_from("<H", payload, 4)[0]
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 6 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_ZONE_LEDS:
zone_index, count = struct.unpack_from("<IH", payload, 4)
assert zone_index == 0
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 10 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_MODE:
active_mode = struct.unpack_from("<i", payload, 4)[0]
if active_mode == 2:
red, green, blue = struct.unpack_from("<BBB", payload, len(payload) - 4)
fixture_static_color = RGBColor(red=red, green=green, blue=blue)
effect_received.set()
elif header.packet_id == PacketId.SET_CUSTOM_MODE:
active_mode = 0
elif header.packet_id == PacketId.RESIZE_ZONE:
resize_received.set()
response: bytes | None = None
if header.packet_id == PacketId.REQUEST_PROTOCOL_VERSION:
response = struct.pack("<I", 5)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_COUNT:
response = struct.pack("<I", 1)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_DATA:
response = controller_packet(
active_mode=active_mode,
controller_colors=fixture_colors,
static_color=fixture_static_color,
)
if response is not None:
writer.write(
pack_header(header.device_index, header.packet_id, len(response)) + response
)
await writer.drain()
except asyncio.IncompleteReadError:
pass
finally:
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
settings = Settings(
LUMAOPS_ENV="test",
auth_enabled=False,
openrgb_host="127.0.0.1",
openrgb_port=port,
openrgb_connect_timeout=1,
openrgb_command_timeout=1,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
database_url=f"sqlite:///{tmp_path / 'test.db'}",
)
adapter = OpenRGBAdapter(settings)
try:
devices = await adapter.inventory()
assert len(devices) == 1
assert devices[0].name == "SDK Fixture"
state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=2, green=4, blue=8)],
brightness=100,
mode_index=1,
),
)
assert state.colors == [RGBColor(red=2, green=4, blue=8)] * 2
assert state.mode == "Direct"
assert state.mode_index == 0
assert state.brightness is None
await adapter.resize_zone(devices[0].external_id, 0, 24)
await asyncio.wait_for(resize_received.wait(), timeout=1)
assert [packet_id for packet_id, _payload in seen[:4]] == [
PacketId.REQUEST_PROTOCOL_VERSION,
PacketId.SET_CLIENT_NAME,
PacketId.REQUEST_CONTROLLER_COUNT,
PacketId.REQUEST_CONTROLLER_DATA,
]
assert any(packet_id == PacketId.SET_CUSTOM_MODE for packet_id, _payload in seen)
assert any(packet_id == PacketId.UPDATE_LEDS for packet_id, _payload in seen)
assert (PacketId.RESIZE_ZONE, struct.pack("<ii", 0, 24)) in seen
zone_start = len(seen)
zone_color = RGBColor(red=9, green=18, blue=27)
zone_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[zone_color], zone_index=0),
)
zone_packets = [packet_id for packet_id, _payload in seen[zone_start:]]
assert zone_state.colors == [zone_color] * 2
assert PacketId.UPDATE_ZONE_LEDS in zone_packets
assert PacketId.SET_CUSTOM_MODE not in zone_packets
effect_start = len(seen)
effect_received.clear()
effect_state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=90, green=45, blue=180)],
brightness=80,
mode_index=2,
),
)
await asyncio.wait_for(effect_received.wait(), timeout=1)
effect_packets = [packet_id for packet_id, _payload in seen[effect_start:]]
assert effect_state.mode == "Static"
assert effect_state.mode_index == 2
assert effect_state.colors == [RGBColor(red=90, green=45, blue=180)]
assert PacketId.UPDATE_MODE in effect_packets
assert PacketId.SET_CUSTOM_MODE not in effect_packets
assert PacketId.UPDATE_LEDS not in effect_packets
direct_start = len(seen)
direct_color = RGBColor(red=24, green=48, blue=96)
direct_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[direct_color]),
)
direct_packets = [packet_id for packet_id, _payload in seen[direct_start:]]
assert direct_state.mode == "Direct"
assert direct_state.colors == [direct_color] * 2
assert PacketId.SET_CUSTOM_MODE in direct_packets
assert PacketId.UPDATE_LEDS in direct_packets
finally:
await adapter.stop()
server.close()
await server.wait_closed()
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import struct
import pytest
from lumaops_backend.connectors.base import RGBColor
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
MAGIC,
Mode,
ModeFlag,
PacketId,
ProtocolError,
pack_color,
pack_header,
pack_mode,
pack_string,
pack_update_leds,
parse_controller,
parse_header,
)
def controller_packet() -> bytes:
color = RGBColor(red=16, green=32, blue=48)
mode = (
pack_string("Static")
+ struct.pack("<i", 7)
+ struct.pack("<I", int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 2, 2, 2, 0)
+ struct.pack("<H", 1)
+ pack_string("Segment A")
+ struct.pack("<iII", 1, 0, 2)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("Test Controller")
+ pack_string("LumaOps")
+ pack_string("Fixture")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 1)
+ struct.pack("<i", 0)
+ mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ pack_color(color)
+ pack_color(color)
+ struct.pack("<H", 2)
+ pack_string("A")
+ pack_string("B")
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_header_roundtrip_and_limit() -> None:
raw = pack_header(3, PacketId.UPDATE_LEDS, 42)
assert len(raw) == HEADER.size
parsed = parse_header(raw)
assert parsed.device_index == 3
assert parsed.packet_id == PacketId.UPDATE_LEDS
assert parsed.payload_size == 42
oversized = HEADER.pack(MAGIC, 0, 0, 4097)
with pytest.raises(ProtocolError):
parse_header(oversized, max_packet_size=4096)
def test_parse_protocol_v5_controller() -> None:
controller = parse_controller(controller_packet(), 4)
assert controller.name == "Test Controller"
assert controller.vendor == "LumaOps"
assert controller.firmware_version == "1.2.3"
assert len(controller.leds) == 2
assert controller.led_alt_names == ["A", "B"]
assert controller.zones[0].segments[0].name == "Segment A"
assert controller.capabilities.brightness is True
assert controller.capabilities.per_led is True
def test_parser_rejects_truncation_and_bad_declared_size() -> None:
packet = controller_packet()
with pytest.raises(ProtocolError):
parse_controller(packet[:-1], 0)
corrupted = struct.pack("<I", len(packet) + 100) + packet[4:]
with pytest.raises(ProtocolError):
parse_controller(corrupted, 0)
def test_led_updates_require_exact_count() -> None:
color = RGBColor(red=1, green=2, blue=3)
payload = pack_update_leds([color, color], 2)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_update_leds([color], 2)
def test_mode_serializer_validates_ranges() -> None:
mode = Mode(
index=0,
name="Pulse",
value=1,
flags=int(ModeFlag.HAS_SPEED | ModeFlag.HAS_BRIGHTNESS),
speed_min=1,
speed_max=10,
brightness_min=0,
brightness_max=255,
colors_min=0,
colors_max=0,
speed=5,
brightness=128,
direction=0,
color_mode=0,
)
payload = pack_mode(mode, 0, brightness_percent=50, speed=7)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_mode(mode, 0, speed=11)
def test_mode_serializer_carries_effect_colors_and_direction() -> None:
mode = Mode(
index=4,
name="Color Wave",
value=4,
flags=int(
ModeFlag.HAS_SPEED
| ModeFlag.HAS_DIRECTION_LR
| ModeFlag.HAS_BRIGHTNESS
| ModeFlag.HAS_MODE_SPECIFIC_COLOR
),
speed_min=0,
speed_max=2,
brightness_min=0,
brightness_max=255,
colors_min=2,
colors_max=2,
speed=1,
brightness=128,
direction=0,
color_mode=1,
)
colors = [RGBColor(red=12, green=34, blue=56), RGBColor(red=78, green=90, blue=123)]
payload = pack_mode(
mode,
mode.index,
brightness_percent=75,
speed=2,
direction=1,
colors=colors,
)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
assert payload.endswith(pack_color(colors[0]) + pack_color(colors[1]))
with pytest.raises(ProtocolError):
pack_mode(mode, mode.index, colors=colors[:1])