private-assistant-display-controller 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- private_assistant_display_controller/__init__.py +39 -0
- private_assistant_display_controller/config.py +110 -0
- private_assistant_display_controller/controller.py +260 -0
- private_assistant_display_controller/display.py +254 -0
- private_assistant_display_controller/exceptions.py +17 -0
- private_assistant_display_controller/main.py +125 -0
- private_assistant_display_controller/minio_client.py +123 -0
- private_assistant_display_controller/models.py +63 -0
- private_assistant_display_controller/mqtt_client.py +212 -0
- private_assistant_display_controller/py.typed +0 -0
- private_assistant_display_controller-0.1.0.dist-info/METADATA +46 -0
- private_assistant_display_controller-0.1.0.dist-info/RECORD +15 -0
- private_assistant_display_controller-0.1.0.dist-info/WHEEL +4 -0
- private_assistant_display_controller-0.1.0.dist-info/entry_points.txt +3 -0
- private_assistant_display_controller-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Inky Display Controller - E-ink display management for Raspberry Pi.
|
|
2
|
+
|
|
3
|
+
This package provides a daemon that receives MQTT commands from the
|
|
4
|
+
picture-display-skill and displays images on an Inky Impression e-ink display.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
from private_assistant_display_controller.config import Settings, load_settings
|
|
10
|
+
from private_assistant_display_controller.controller import DisplayController
|
|
11
|
+
from private_assistant_display_controller.exceptions import (
|
|
12
|
+
CommunicationError,
|
|
13
|
+
ConfigurationError,
|
|
14
|
+
DisplayControllerError,
|
|
15
|
+
DisplayError,
|
|
16
|
+
)
|
|
17
|
+
from private_assistant_display_controller.models import (
|
|
18
|
+
DeviceAcknowledge,
|
|
19
|
+
DeviceRegistration,
|
|
20
|
+
DisplayCommand,
|
|
21
|
+
DisplayInfo,
|
|
22
|
+
RegistrationResponse,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CommunicationError",
|
|
27
|
+
"ConfigurationError",
|
|
28
|
+
"DeviceAcknowledge",
|
|
29
|
+
"DeviceRegistration",
|
|
30
|
+
"DisplayCommand",
|
|
31
|
+
"DisplayController",
|
|
32
|
+
"DisplayControllerError",
|
|
33
|
+
"DisplayError",
|
|
34
|
+
"DisplayInfo",
|
|
35
|
+
"RegistrationResponse",
|
|
36
|
+
"Settings",
|
|
37
|
+
"__version__",
|
|
38
|
+
"load_settings",
|
|
39
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Configuration management using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import Field, SecretStr
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DeviceConfig(BaseSettings):
|
|
12
|
+
"""Device identification settings."""
|
|
13
|
+
|
|
14
|
+
id: str = Field(default="inky-display", description="Unique device identifier")
|
|
15
|
+
room: str | None = Field(default=None, description="Room where device is located")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MQTTConfig(BaseSettings):
|
|
19
|
+
"""MQTT broker connection settings."""
|
|
20
|
+
|
|
21
|
+
host: str = Field(default="localhost", description="MQTT broker hostname")
|
|
22
|
+
port: int = Field(default=1883, description="MQTT broker port")
|
|
23
|
+
username: str | None = Field(default=None, description="MQTT username")
|
|
24
|
+
password: SecretStr | None = Field(default=None, description="MQTT password")
|
|
25
|
+
client_id: str | None = Field(default=None, description="MQTT client ID (auto-generated if not set)")
|
|
26
|
+
transport: Literal["tcp", "websockets"] = Field(default="tcp", description="MQTT transport protocol")
|
|
27
|
+
websocket_path: str | None = Field(default=None, description="WebSocket path (e.g., '/mqtt')")
|
|
28
|
+
tls: bool = Field(default=False, description="Enable TLS/SSL encryption")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MinIOConfig(BaseSettings):
|
|
32
|
+
"""MinIO connection settings.
|
|
33
|
+
|
|
34
|
+
These are typically populated from the registration response,
|
|
35
|
+
but can be pre-configured via environment variables.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
endpoint: str = Field(default="localhost:9000", description="MinIO server endpoint")
|
|
39
|
+
bucket: str = Field(default="inky-images", description="Bucket containing images")
|
|
40
|
+
access_key: SecretStr | None = Field(default=None, description="MinIO access key")
|
|
41
|
+
secret_key: SecretStr | None = Field(default=None, description="MinIO secret key")
|
|
42
|
+
secure: bool = Field(default=False, description="Use HTTPS for MinIO connection")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DisplayConfig(BaseSettings):
|
|
46
|
+
"""Display hardware settings."""
|
|
47
|
+
|
|
48
|
+
orientation: Literal["landscape", "portrait"] = Field(default="landscape", description="Display orientation")
|
|
49
|
+
saturation: float = Field(default=0.5, ge=0.0, le=1.0, description="Color saturation for Spectra 6")
|
|
50
|
+
mock: bool = Field(default=False, description="Use mock display for testing without hardware")
|
|
51
|
+
# Only used when mock=True (no hardware to query)
|
|
52
|
+
mock_width: int = Field(default=1600, gt=0, description="Mock display width in pixels")
|
|
53
|
+
mock_height: int = Field(default=1200, gt=0, description="Mock display height in pixels")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Settings(BaseSettings):
|
|
57
|
+
"""Main application settings aggregating all configuration sections."""
|
|
58
|
+
|
|
59
|
+
model_config = SettingsConfigDict(
|
|
60
|
+
env_nested_delimiter="__",
|
|
61
|
+
extra="ignore",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
device: DeviceConfig = Field(default_factory=DeviceConfig)
|
|
65
|
+
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
|
66
|
+
minio: MinIOConfig = Field(default_factory=MinIOConfig)
|
|
67
|
+
display: DisplayConfig = Field(default_factory=DisplayConfig)
|
|
68
|
+
|
|
69
|
+
config_file: Path | None = Field(default=None, description="Path to YAML configuration file")
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_yaml(cls, yaml_path: Path) -> "Settings":
|
|
73
|
+
"""Load settings from a YAML configuration file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
yaml_path: Path to the YAML configuration file.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Settings instance with values from YAML merged with env vars.
|
|
80
|
+
"""
|
|
81
|
+
with yaml_path.open() as f:
|
|
82
|
+
yaml_config = yaml.safe_load(f) or {}
|
|
83
|
+
|
|
84
|
+
# Build nested config from YAML
|
|
85
|
+
device_config = DeviceConfig(**yaml_config.get("device", {}))
|
|
86
|
+
mqtt_config = MQTTConfig(**yaml_config.get("mqtt", {}))
|
|
87
|
+
minio_config = MinIOConfig(**yaml_config.get("minio", {}))
|
|
88
|
+
display_config = DisplayConfig(**yaml_config.get("display", {}))
|
|
89
|
+
|
|
90
|
+
return cls(
|
|
91
|
+
device=device_config,
|
|
92
|
+
mqtt=mqtt_config,
|
|
93
|
+
minio=minio_config,
|
|
94
|
+
display=display_config,
|
|
95
|
+
config_file=yaml_path,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_settings(config_path: Path | None = None) -> Settings:
|
|
100
|
+
"""Load application settings from config file and environment variables.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
config_path: Optional path to YAML configuration file.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Settings instance with merged configuration.
|
|
107
|
+
"""
|
|
108
|
+
if config_path and config_path.exists():
|
|
109
|
+
return Settings.from_yaml(config_path)
|
|
110
|
+
return Settings()
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Main controller orchestrating all display controller components."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from private_assistant_display_controller.config import Settings
|
|
7
|
+
from private_assistant_display_controller.display import DisplayInterface, create_display
|
|
8
|
+
from private_assistant_display_controller.exceptions import CommunicationError, DisplayError
|
|
9
|
+
from private_assistant_display_controller.minio_client import MinIOImageClient
|
|
10
|
+
from private_assistant_display_controller.models import (
|
|
11
|
+
DeviceAcknowledge,
|
|
12
|
+
DeviceRegistration,
|
|
13
|
+
DisplayCommand,
|
|
14
|
+
DisplayInfo,
|
|
15
|
+
RegistrationResponse,
|
|
16
|
+
)
|
|
17
|
+
from private_assistant_display_controller.mqtt_client import MQTTClient
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DisplayController:
|
|
23
|
+
"""Main controller orchestrating display operations.
|
|
24
|
+
|
|
25
|
+
Coordinates MQTT communication, MinIO image fetching, and display updates.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, settings: Settings) -> None:
|
|
29
|
+
"""Initialize the display controller.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
settings: Application settings.
|
|
33
|
+
"""
|
|
34
|
+
self._settings = settings
|
|
35
|
+
self._current_image_id: str | None = None
|
|
36
|
+
self._is_registered = asyncio.Event()
|
|
37
|
+
self._shutdown_event = asyncio.Event()
|
|
38
|
+
|
|
39
|
+
# Initialize components
|
|
40
|
+
self._minio = MinIOImageClient()
|
|
41
|
+
self._display: DisplayInterface = create_display(
|
|
42
|
+
mock=settings.display.mock,
|
|
43
|
+
orientation=settings.display.orientation,
|
|
44
|
+
mock_width=settings.display.mock_width,
|
|
45
|
+
mock_height=settings.display.mock_height,
|
|
46
|
+
)
|
|
47
|
+
self._mqtt = MQTTClient(
|
|
48
|
+
config=settings.mqtt,
|
|
49
|
+
device_id=settings.device.id,
|
|
50
|
+
on_command=self._handle_command,
|
|
51
|
+
on_registration_response=self._handle_registration_response,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def run(self) -> None:
|
|
55
|
+
"""Main entry point - start all async tasks.
|
|
56
|
+
|
|
57
|
+
Runs until shutdown is requested via shutdown() method.
|
|
58
|
+
"""
|
|
59
|
+
logger.info("Starting display controller for device: %s", self._settings.device.id)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
async with asyncio.TaskGroup() as tg:
|
|
63
|
+
tg.create_task(self._mqtt.run(), name="mqtt")
|
|
64
|
+
tg.create_task(self._registration_loop(), name="registration")
|
|
65
|
+
tg.create_task(self._shutdown_monitor(), name="shutdown_monitor")
|
|
66
|
+
except* Exception as eg:
|
|
67
|
+
for exc in eg.exceptions:
|
|
68
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
69
|
+
logger.exception("Task failed: %s", exc)
|
|
70
|
+
finally:
|
|
71
|
+
await self._cleanup()
|
|
72
|
+
|
|
73
|
+
async def shutdown(self) -> None:
|
|
74
|
+
"""Request graceful shutdown."""
|
|
75
|
+
logger.info("Shutdown requested")
|
|
76
|
+
self._shutdown_event.set()
|
|
77
|
+
|
|
78
|
+
async def _shutdown_monitor(self) -> None:
|
|
79
|
+
"""Monitor for shutdown signal and cancel tasks."""
|
|
80
|
+
await self._shutdown_event.wait()
|
|
81
|
+
raise asyncio.CancelledError("Shutdown requested")
|
|
82
|
+
|
|
83
|
+
async def _cleanup(self) -> None:
|
|
84
|
+
"""Clean up resources on shutdown."""
|
|
85
|
+
logger.info("Cleaning up resources...")
|
|
86
|
+
self._minio.close()
|
|
87
|
+
if hasattr(self._display, "close"):
|
|
88
|
+
self._display.close() # type: ignore[attr-defined]
|
|
89
|
+
await self._mqtt.disconnect()
|
|
90
|
+
|
|
91
|
+
async def _registration_loop(self) -> None:
|
|
92
|
+
"""Send registration on startup and retry until acknowledged.
|
|
93
|
+
|
|
94
|
+
Runs until registration is successful, then exits.
|
|
95
|
+
"""
|
|
96
|
+
registration = DeviceRegistration(
|
|
97
|
+
device_id=self._settings.device.id,
|
|
98
|
+
display=DisplayInfo(
|
|
99
|
+
width=self._display.width,
|
|
100
|
+
height=self._display.height,
|
|
101
|
+
orientation=self._settings.display.orientation,
|
|
102
|
+
),
|
|
103
|
+
room=self._settings.device.room,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
retry_interval = 10
|
|
107
|
+
max_retry_interval = 60
|
|
108
|
+
|
|
109
|
+
while not self._is_registered.is_set():
|
|
110
|
+
try:
|
|
111
|
+
logger.info("Sending registration...")
|
|
112
|
+
await self._mqtt.publish_registration(registration)
|
|
113
|
+
|
|
114
|
+
# Wait for response with timeout
|
|
115
|
+
try:
|
|
116
|
+
await asyncio.wait_for(
|
|
117
|
+
self._is_registered.wait(),
|
|
118
|
+
timeout=30.0,
|
|
119
|
+
)
|
|
120
|
+
logger.info("Registration successful")
|
|
121
|
+
return
|
|
122
|
+
except TimeoutError:
|
|
123
|
+
logger.warning(
|
|
124
|
+
"Registration response timeout. Retrying in %d seconds...",
|
|
125
|
+
retry_interval,
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.exception("Registration failed")
|
|
129
|
+
|
|
130
|
+
await asyncio.sleep(retry_interval)
|
|
131
|
+
retry_interval = min(retry_interval * 2, max_retry_interval)
|
|
132
|
+
|
|
133
|
+
async def _handle_registration_response(self, response: RegistrationResponse) -> None:
|
|
134
|
+
"""Process registration response and configure MinIO client.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
response: Registration response containing MinIO credentials.
|
|
138
|
+
"""
|
|
139
|
+
logger.info(
|
|
140
|
+
"Received registration response: status=%s, endpoint=%s",
|
|
141
|
+
response.status,
|
|
142
|
+
response.minio_endpoint,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
self._minio.configure(
|
|
146
|
+
endpoint=response.minio_endpoint,
|
|
147
|
+
access_key=response.minio_access_key,
|
|
148
|
+
secret_key=response.minio_secret_key,
|
|
149
|
+
bucket=response.minio_bucket,
|
|
150
|
+
secure=response.minio_secure,
|
|
151
|
+
)
|
|
152
|
+
self._is_registered.set()
|
|
153
|
+
|
|
154
|
+
async def _handle_command(self, command: DisplayCommand) -> None:
|
|
155
|
+
"""Process incoming display commands.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
command: Command to process.
|
|
159
|
+
"""
|
|
160
|
+
logger.info("Received command: action=%s, image_id=%s", command.action, command.image_id)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
match command.action:
|
|
164
|
+
case "display":
|
|
165
|
+
await self._handle_display(command)
|
|
166
|
+
case "clear":
|
|
167
|
+
await self._handle_clear()
|
|
168
|
+
case "status":
|
|
169
|
+
await self._send_acknowledge(success=True)
|
|
170
|
+
case _:
|
|
171
|
+
logger.warning("Unknown command action: %s", command.action)
|
|
172
|
+
await self._send_acknowledge(
|
|
173
|
+
image_id=command.image_id,
|
|
174
|
+
success=False,
|
|
175
|
+
error=f"Unknown action: {command.action}",
|
|
176
|
+
)
|
|
177
|
+
except (CommunicationError, DisplayError) as e:
|
|
178
|
+
logger.exception("Command failed: %s", command.action)
|
|
179
|
+
await self._send_acknowledge(
|
|
180
|
+
image_id=command.image_id,
|
|
181
|
+
success=False,
|
|
182
|
+
error=str(e),
|
|
183
|
+
)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.exception("Unexpected error handling command")
|
|
186
|
+
await self._send_acknowledge(
|
|
187
|
+
image_id=command.image_id,
|
|
188
|
+
success=False,
|
|
189
|
+
error=f"Unexpected error: {e}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def _handle_display(self, command: DisplayCommand) -> None:
|
|
193
|
+
"""Fetch and display an image.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
command: Display command with image path.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If image_path or image_id is missing.
|
|
200
|
+
CommunicationError: If MinIO fetch fails.
|
|
201
|
+
DisplayError: If display update fails.
|
|
202
|
+
"""
|
|
203
|
+
if not command.image_path or not command.image_id:
|
|
204
|
+
raise ValueError("display command requires image_path and image_id")
|
|
205
|
+
|
|
206
|
+
if not self._minio.is_configured:
|
|
207
|
+
raise CommunicationError("MinIO not configured - awaiting registration")
|
|
208
|
+
|
|
209
|
+
logger.info("Fetching image: %s", command.image_path)
|
|
210
|
+
image = await self._minio.fetch_image(command.image_path)
|
|
211
|
+
|
|
212
|
+
logger.info("Displaying image: %s", command.image_id)
|
|
213
|
+
await self._display.show_image(
|
|
214
|
+
image,
|
|
215
|
+
saturation=self._settings.display.saturation,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
self._current_image_id = command.image_id
|
|
219
|
+
|
|
220
|
+
await self._send_acknowledge(
|
|
221
|
+
image_id=command.image_id,
|
|
222
|
+
success=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def _handle_clear(self) -> None:
|
|
226
|
+
"""Clear the display.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
DisplayError: If display clear fails.
|
|
230
|
+
"""
|
|
231
|
+
logger.info("Clearing display")
|
|
232
|
+
await self._display.clear()
|
|
233
|
+
self._current_image_id = None
|
|
234
|
+
|
|
235
|
+
await self._send_acknowledge(success=True)
|
|
236
|
+
|
|
237
|
+
async def _send_acknowledge(
|
|
238
|
+
self,
|
|
239
|
+
image_id: str | None = None,
|
|
240
|
+
success: bool = True,
|
|
241
|
+
error: str | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Send acknowledgment after command processing.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
image_id: Image ID if applicable.
|
|
247
|
+
success: Whether the command was successful.
|
|
248
|
+
error: Error message if command failed.
|
|
249
|
+
"""
|
|
250
|
+
acknowledge = DeviceAcknowledge(
|
|
251
|
+
device_id=self._settings.device.id,
|
|
252
|
+
image_id=image_id or self._current_image_id,
|
|
253
|
+
successful_display_change=success,
|
|
254
|
+
error=error,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
await self._mqtt.publish_acknowledge(acknowledge)
|
|
259
|
+
except Exception:
|
|
260
|
+
logger.exception("Failed to send acknowledgment")
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Display abstraction layer for Inky e-paper displays."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from private_assistant_display_controller.exceptions import DisplayError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DisplayInterface(ABC):
|
|
17
|
+
"""Abstract interface for display implementations."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def width(self) -> int:
|
|
22
|
+
"""Display width in pixels."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def height(self) -> int:
|
|
27
|
+
"""Display height in pixels."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
31
|
+
"""Display an image on the screen.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
image: PIL Image to display.
|
|
35
|
+
saturation: Color saturation for Spectra 6 displays (0.0-1.0).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def clear(self) -> None:
|
|
40
|
+
"""Clear the display to white."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InkyDisplay(DisplayInterface):
|
|
44
|
+
"""Wrapper for Pimoroni Inky e-paper displays.
|
|
45
|
+
|
|
46
|
+
The display refresh is a blocking operation (~20-25 seconds),
|
|
47
|
+
so it runs in a dedicated thread pool to avoid blocking the async loop.
|
|
48
|
+
|
|
49
|
+
Dimensions are auto-detected from the hardware during initialization.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
orientation: Literal["landscape", "portrait"] = "landscape",
|
|
55
|
+
executor: ThreadPoolExecutor | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Initialize the Inky display wrapper.
|
|
58
|
+
|
|
59
|
+
Connects to hardware immediately to detect display dimensions.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
orientation: Display orientation.
|
|
63
|
+
executor: Optional thread pool executor for display operations.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
DisplayError: If the display cannot be initialized.
|
|
67
|
+
"""
|
|
68
|
+
self._orientation = orientation
|
|
69
|
+
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="inky")
|
|
70
|
+
self._lock = asyncio.Lock()
|
|
71
|
+
|
|
72
|
+
# Eager init - get dimensions from hardware
|
|
73
|
+
try:
|
|
74
|
+
from inky.auto import auto # noqa: PLC0415
|
|
75
|
+
|
|
76
|
+
self._display = auto()
|
|
77
|
+
self._width: int = self._display.width # type: ignore[union-attr]
|
|
78
|
+
self._height: int = self._display.height # type: ignore[union-attr]
|
|
79
|
+
logger.info("Inky display initialized: %dx%d", self._width, self._height)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.exception("Failed to initialize Inky display")
|
|
82
|
+
raise DisplayError(f"Failed to initialize display: {e}") from e
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def width(self) -> int:
|
|
86
|
+
"""Display width in pixels."""
|
|
87
|
+
return self._width
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def height(self) -> int:
|
|
91
|
+
"""Display height in pixels."""
|
|
92
|
+
return self._height
|
|
93
|
+
|
|
94
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
95
|
+
"""Display an image on the Inky screen.
|
|
96
|
+
|
|
97
|
+
Runs the blocking display update in a thread pool.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
image: PIL Image to display.
|
|
101
|
+
saturation: Color saturation (0.0-1.0).
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
DisplayError: If the display update fails.
|
|
105
|
+
"""
|
|
106
|
+
async with self._lock:
|
|
107
|
+
loop = asyncio.get_event_loop()
|
|
108
|
+
try:
|
|
109
|
+
await loop.run_in_executor(
|
|
110
|
+
self._executor,
|
|
111
|
+
self._show_image_sync,
|
|
112
|
+
image,
|
|
113
|
+
saturation,
|
|
114
|
+
)
|
|
115
|
+
except DisplayError:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.exception("Failed to update display")
|
|
119
|
+
raise DisplayError(f"Display update failed: {e}") from e
|
|
120
|
+
|
|
121
|
+
def _show_image_sync(self, image: Image.Image, saturation: float) -> None:
|
|
122
|
+
"""Synchronous display update implementation.
|
|
123
|
+
|
|
124
|
+
This method blocks for ~20-25 seconds during the e-ink refresh.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
image: PIL Image to display.
|
|
128
|
+
saturation: Color saturation (0.0-1.0).
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
DisplayError: If image dimensions don't match display dimensions.
|
|
132
|
+
"""
|
|
133
|
+
# Validate image dimensions - skill is responsible for correct sizing
|
|
134
|
+
if image.size != (self.width, self.height):
|
|
135
|
+
raise DisplayError(
|
|
136
|
+
f"Image size {image.size[0]}x{image.size[1]} does not match "
|
|
137
|
+
f"display size {self.width}x{self.height}. "
|
|
138
|
+
"The skill must provide correctly sized images."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
logger.info("Updating display (this takes ~20-25 seconds)...")
|
|
142
|
+
self._display.set_image(image, saturation=saturation) # type: ignore[union-attr]
|
|
143
|
+
self._display.show(busy_wait=True) # type: ignore[union-attr]
|
|
144
|
+
logger.info("Display update complete")
|
|
145
|
+
|
|
146
|
+
async def clear(self) -> None:
|
|
147
|
+
"""Clear the display to white."""
|
|
148
|
+
white_image = Image.new("RGB", (self.width, self.height), (255, 255, 255))
|
|
149
|
+
await self.show_image(white_image)
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
"""Clean up resources."""
|
|
153
|
+
if self._executor:
|
|
154
|
+
self._executor.shutdown(wait=False)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MockDisplay(DisplayInterface):
|
|
158
|
+
"""Mock display for testing without hardware.
|
|
159
|
+
|
|
160
|
+
Stores the last displayed image for inspection in tests.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, width: int = 1600, height: int = 1200) -> None:
|
|
164
|
+
"""Initialize the mock display.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
width: Simulated display width.
|
|
168
|
+
height: Simulated display height.
|
|
169
|
+
"""
|
|
170
|
+
self._width = width
|
|
171
|
+
self._height = height
|
|
172
|
+
self._last_image: Image.Image | None = None
|
|
173
|
+
self._display_count = 0
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def width(self) -> int:
|
|
177
|
+
"""Display width in pixels."""
|
|
178
|
+
return self._width
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def height(self) -> int:
|
|
182
|
+
"""Display height in pixels."""
|
|
183
|
+
return self._height
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def last_image(self) -> Image.Image | None:
|
|
187
|
+
"""The last image that was displayed."""
|
|
188
|
+
return self._last_image
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def display_count(self) -> int:
|
|
192
|
+
"""Number of times show_image was called."""
|
|
193
|
+
return self._display_count
|
|
194
|
+
|
|
195
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
196
|
+
"""Store the image for inspection.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
image: PIL Image to "display".
|
|
200
|
+
saturation: Color saturation (ignored in mock).
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
DisplayError: If image dimensions don't match display dimensions.
|
|
204
|
+
"""
|
|
205
|
+
_ = saturation # Unused in mock, but part of interface
|
|
206
|
+
|
|
207
|
+
# Validate image dimensions - same behavior as real display
|
|
208
|
+
if image.size != (self._width, self._height):
|
|
209
|
+
raise DisplayError(
|
|
210
|
+
f"Image size {image.size[0]}x{image.size[1]} does not match "
|
|
211
|
+
f"display size {self._width}x{self._height}. "
|
|
212
|
+
"The skill must provide correctly sized images."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self._last_image = image.copy()
|
|
216
|
+
self._display_count += 1
|
|
217
|
+
logger.debug("Mock display: stored image %dx%d", image.width, image.height)
|
|
218
|
+
# Simulate a brief delay (real display takes ~25s)
|
|
219
|
+
await asyncio.sleep(0.1)
|
|
220
|
+
|
|
221
|
+
async def clear(self) -> None:
|
|
222
|
+
"""Clear the mock display."""
|
|
223
|
+
self._last_image = None
|
|
224
|
+
self._display_count += 1
|
|
225
|
+
logger.debug("Mock display: cleared")
|
|
226
|
+
await asyncio.sleep(0.1)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def create_display(
|
|
230
|
+
mock: bool = False,
|
|
231
|
+
orientation: Literal["landscape", "portrait"] = "landscape",
|
|
232
|
+
mock_width: int = 1600,
|
|
233
|
+
mock_height: int = 1200,
|
|
234
|
+
) -> DisplayInterface:
|
|
235
|
+
"""Factory function to create the appropriate display implementation.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
mock: If True, create a MockDisplay for testing.
|
|
239
|
+
orientation: Display orientation for real hardware.
|
|
240
|
+
mock_width: Width for mock display (ignored for real hardware).
|
|
241
|
+
mock_height: Height for mock display (ignored for real hardware).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
DisplayInterface implementation.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
DisplayError: If real hardware initialization fails.
|
|
248
|
+
"""
|
|
249
|
+
if mock:
|
|
250
|
+
logger.info("Creating mock display (%dx%d)", mock_width, mock_height)
|
|
251
|
+
return MockDisplay(width=mock_width, height=mock_height)
|
|
252
|
+
|
|
253
|
+
logger.info("Creating Inky display with orientation: %s", orientation)
|
|
254
|
+
return InkyDisplay(orientation=orientation)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exception hierarchy for the display controller."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DisplayControllerError(Exception):
|
|
5
|
+
"""Base exception for all display controller errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(DisplayControllerError):
|
|
9
|
+
"""Raised when configuration is invalid or missing."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommunicationError(DisplayControllerError):
|
|
13
|
+
"""Raised when MQTT or MinIO communication fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DisplayError(DisplayControllerError):
|
|
17
|
+
"""Raised when a display hardware operation fails."""
|