feat: initial project scaffold — server, client, Android APK

- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking,
  notifications (progress + alerts), telemetry, and system tray .exe build
- Test suite covering notifications, audio mixer, media, telemetry, and API
- E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures)
- Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin)
- Design decision records in .pi/docs/design/
- PyInstaller build script for server .exe
This commit is contained in:
Imrayya
2026-07-01 11:19:32 +00:00
commit 0b4f739d12
36 changed files with 3916 additions and 0 deletions
View File
+9
View File
@@ -0,0 +1,9 @@
"""Shared test configuration."""
from __future__ import annotations
import sys
import os
# Ensure the server package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the audio mixer data models and backend interface."""
from __future__ import annotations
import pytest
from ..audio_mixer import (
AudioChannel,
AudioMixerManager,
AudioMixerState,
)
class TestDataModels:
def test_audio_channel_defaults(self) -> None:
ch = AudioChannel(name="Master")
assert ch.name == "Master"
assert ch.volume == 0.0
assert ch.muted == False
assert ch.peak_left == 0.0
def test_audio_mixer_state_defaults(self) -> None:
state = AudioMixerState(backend="windows")
assert state.backend == "windows"
assert state.master_volume == 0.0
assert state.master_muted == False
assert state.channels == []
def test_audio_mixer_state_with_channels(self) -> None:
channels = [
AudioChannel(name="A1", volume=0.8, muted=False),
AudioChannel(name="B1", volume=0.5, muted=True),
]
state = AudioMixerState(
backend="voicemeeter",
master_volume=0.65,
master_muted=False,
channels=channels,
)
assert len(state.channels) == 2
assert state.channels[0].name == "A1"
assert state.channels[1].muted == True
class TestAudioMixerManager:
def test_init_with_no_backends(self) -> None:
"""Manager should initialize even with no backends available."""
manager = AudioMixerManager()
# Should not raise
state = manager.get_state()
assert isinstance(state, dict)
assert "backend" in state
assert "master_volume" in state
assert "channels" in state
def test_get_state_returns_dict(self) -> None:
manager = AudioMixerManager()
state = manager.get_state()
assert isinstance(state, dict)
assert state["backend"] in ("windows", "voicemeeter")
assert isinstance(state["channels"], list)
@pytest.mark.asyncio
async def test_set_volume_no_backends(self) -> None:
"""set_volume should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_volume("master", 0.5) # Should not raise
@pytest.mark.asyncio
async def test_set_mute_no_backends(self) -> None:
"""set_mute should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_mute("master", True) # Should not raise
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the FastAPI application endpoints."""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from ..main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class TestHealth:
def test_health_returns_ok(self, client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestGetState:
def test_get_state_returns_dict(self, client: TestClient) -> None:
with (
patch.object(app.state, "media", create=True),
patch.object(app.state, "audio", create=True),
patch.object(app.state, "notifications", create=True),
patch.object(app.state, "telemetry", create=True),
):
app.state.media.get_state = lambda: {"title": "Test"}
app.state.audio.get_state = lambda: {"backend": "windows"}
app.state.notifications.get_active = list
app.state.telemetry.get_state = dict
response = client.get("/api/v1/state")
assert response.status_code == 200
data = response.json()
assert "media" in data
assert "audio" in data
assert "notifications" in data
assert "telemetry" in data
class TestNotifyEndpoint:
def test_notify_without_token_returns_401(self, client: TestClient) -> None:
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Test",
"message": "Hello",
},
)
assert response.status_code == 401
def test_notify_with_valid_token(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "test-token-123"
with patch.object(app.state, "notifications", create=True) as mock_notif:
mock_notif.create = AsyncMock()
mock_notif.create.return_value = type(
"Notification", (), {"id": "test-id"}
)()
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Build Complete",
"message": "Success",
},
headers={"Authorization": "Bearer test-token-123"},
)
assert response.status_code == 200
assert response.json()["status"] == "created"
# Cleanup
del os.environ["API_TOKEN"]
def test_notify_with_wrong_token_returns_401(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "correct-token"
response = client.post(
"/api/v1/notify",
json={"type": "alert", "title": "Test", "message": "Hello"},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
del os.environ["API_TOKEN"]
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the media tracking module."""
from __future__ import annotations
from ..media import MediaManager, MediaState
class TestMediaState:
def test_defaults(self) -> None:
state = MediaState()
assert state.title == ""
assert state.artist == ""
assert not state.is_playing
assert state.is_stopped
assert state.playback_percentage == 0.0
def test_playing_state(self) -> None:
state = MediaState(
title="Test Song",
artist="Test Artist",
is_playing=True,
is_paused=False,
is_stopped=False,
playback_percentage=45.5,
)
assert state.title == "Test Song"
assert state.is_playing
assert state.playback_percentage == 45.5
class TestMediaManager:
def test_init(self) -> None:
manager = MediaManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "title" in state
assert "artist" in state
assert "is_playing" in state
assert "playback_percentage" in state
def test_get_state_empty_when_nothing_playing(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert state["title"] == ""
assert not state["is_playing"]
assert state["is_stopped"]
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the notification system."""
from __future__ import annotations
import pytest
from ..notifications import Notification, NotificationManager, ProgressState
@pytest.fixture
def manager() -> NotificationManager:
return NotificationManager()
class TestNotificationCreation:
@pytest.mark.asyncio
async def test_create_alert(self, manager: NotificationManager) -> None:
notif = Notification(
type="alert",
title="Build Complete",
message="Deployment succeeded.",
priority="info",
)
result = await manager.create(notif)
assert result.id
assert result.type == "alert"
assert result.title == "Build Complete"
@pytest.mark.asyncio
async def test_create_progress(self, manager: NotificationManager) -> None:
notif = Notification(
type="progress",
title="Backup",
message="Uploading...",
progress=ProgressState(current=50, min=0, max=100, unit="MB"),
)
result = await manager.create(notif)
assert result.type == "progress"
assert result.progress is not None
progress = result.progress
assert progress.percentage == 50.0
@pytest.mark.asyncio
async def test_update_existing_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(
id="test_01",
type="progress",
title="Backup",
progress=ProgressState(current=10, max=100),
)
await manager.create(notif)
notif.progress = ProgressState(current=50, max=100)
result = await manager.create(notif)
assert result.progress is not None
assert result.progress.percentage == 50.0
@pytest.mark.asyncio
async def test_get_active_returns_list(self, manager: NotificationManager) -> None:
await manager.create(Notification(title="Test"))
active = manager.get_active()
assert len(active) == 1
assert active[0]["title"] == "Test"
class TestNotificationPruning:
@pytest.mark.asyncio
async def test_prunes_old_notifications(self) -> None:
manager = NotificationManager()
manager._max_active = 3
for i in range(5):
await manager.create(Notification(id=f"n{i}", title=f"Msg {i}"))
active = manager.get_active()
assert len(active) == 3
ids = [n["id"] for n in active]
assert "n0" not in ids
assert "n4" in ids
class TestNotificationDismiss:
@pytest.mark.asyncio
async def test_dismiss_removes_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(id="dismiss_me", title="Temp")
await manager.create(notif)
assert len(manager.get_active()) == 1
result = await manager.dismiss("dismiss_me")
assert result
assert len(manager.get_active()) == 0
@pytest.mark.asyncio
async def test_dismiss_nonexistent_returns_false(
self, manager: NotificationManager
) -> None:
result = await manager.dismiss("does_not_exist")
assert not result
class TestProgressState:
def test_percentage_calculation(self) -> None:
p = ProgressState(current=75, min=0, max=100)
assert p.percentage == 75.0
def test_percentage_clamped_at_zero(self) -> None:
p = ProgressState(current=-10, min=0, max=100)
assert p.percentage == 0.0
def test_percentage_clamped_at_hundred(self) -> None:
p = ProgressState(current=150, min=0, max=100)
assert p.percentage == 100.0
def test_zero_range_returns_zero(self) -> None:
p = ProgressState(current=50, min=0, max=0)
assert p.percentage == 0.0
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the telemetry module."""
from __future__ import annotations
from ..telemetry import SensorReading, TelemetryManager, TelemetryState
class TestSensorReading:
def test_defaults(self) -> None:
reading = SensorReading(name="CPU Temp", value=65.0, unit="C")
assert reading.name == "CPU Temp"
assert reading.value == 65.0
assert reading.unit == "C"
assert reading.category == ""
def test_with_category(self) -> None:
reading = SensorReading(name="GPU Usage", value=72.0, unit="%", category="GPU")
assert reading.category == "GPU"
class TestTelemetryState:
def test_defaults(self) -> None:
state = TelemetryState()
assert state.cpu_temp == 0.0
assert state.gpu_temp == 0.0
assert state.sensors == []
def test_with_values(self) -> None:
state = TelemetryState(
cpu_temp=72.5,
gpu_temp=68.0,
cpu_usage=45.0,
sensors=[
SensorReading(name="CPU", value=72.5, unit="C"),
],
)
assert state.cpu_temp == 72.5
assert len(state.sensors) == 1
class TestTelemetryManager:
def test_init(self) -> None:
manager = TelemetryManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "cpu_temp" in state
assert "gpu_temp" in state
assert "sensors" in state
def test_get_state_empty(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert state["cpu_temp"] == 0.0
assert state["sensors"] == []