Publish LumaOps source
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user