inky-image-display-controller 0.14.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.
- inky_image_display_controller/__init__.py +40 -0
- inky_image_display_controller/config.py +107 -0
- inky_image_display_controller/controller.py +234 -0
- inky_image_display_controller/display.py +269 -0
- inky_image_display_controller/exceptions.py +17 -0
- inky_image_display_controller/main.py +126 -0
- inky_image_display_controller/py.typed +1 -0
- inky_image_display_controller/s3_client.py +132 -0
- inky_image_display_controller/ws_client.py +142 -0
- inky_image_display_controller-0.14.0.dist-info/METADATA +18 -0
- inky_image_display_controller-0.14.0.dist-info/RECORD +13 -0
- inky_image_display_controller-0.14.0.dist-info/WHEEL +4 -0
- inky_image_display_controller-0.14.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Inky Display Controller - E-ink display management for Raspberry Pi.
|
|
2
|
+
|
|
3
|
+
This package provides a daemon that receives WebSocket commands from the
|
|
4
|
+
inky-image-display API and displays images on an Inky Impression e-ink display.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.2"
|
|
8
|
+
|
|
9
|
+
from inky_image_display_shared.schemas import (
|
|
10
|
+
DeviceAcknowledge,
|
|
11
|
+
DeviceRegistration,
|
|
12
|
+
DisplayCommand,
|
|
13
|
+
DisplayInfo,
|
|
14
|
+
RegistrationResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from inky_image_display_controller.config import Settings, load_settings
|
|
18
|
+
from inky_image_display_controller.controller import DisplayController
|
|
19
|
+
from inky_image_display_controller.exceptions import (
|
|
20
|
+
CommunicationError,
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
DisplayControllerError,
|
|
23
|
+
DisplayError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CommunicationError",
|
|
28
|
+
"ConfigurationError",
|
|
29
|
+
"DeviceAcknowledge",
|
|
30
|
+
"DeviceRegistration",
|
|
31
|
+
"DisplayCommand",
|
|
32
|
+
"DisplayController",
|
|
33
|
+
"DisplayControllerError",
|
|
34
|
+
"DisplayError",
|
|
35
|
+
"DisplayInfo",
|
|
36
|
+
"RegistrationResponse",
|
|
37
|
+
"Settings",
|
|
38
|
+
"__version__",
|
|
39
|
+
"load_settings",
|
|
40
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
|
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 APIConfig(BaseSettings):
|
|
19
|
+
"""API server connection settings."""
|
|
20
|
+
|
|
21
|
+
url: str = Field(default="ws://localhost:8000", description="API base URL (ws:// or wss://)")
|
|
22
|
+
reconnect_interval: int = Field(default=5, description="Initial reconnect delay in seconds")
|
|
23
|
+
max_reconnect_interval: int = Field(default=60, description="Maximum reconnect delay")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class S3Config(BaseSettings):
|
|
27
|
+
"""S3-compatible object storage connection settings.
|
|
28
|
+
|
|
29
|
+
These are typically populated from the registration response,
|
|
30
|
+
but can be pre-configured via environment variables.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
endpoint: str = Field(default="localhost:9000", description="S3 server endpoint")
|
|
34
|
+
bucket: str = Field(default="inky-images", description="Bucket containing images")
|
|
35
|
+
access_key: str | None = Field(default=None, description="S3 access key")
|
|
36
|
+
secret_key: str | None = Field(default=None, description="S3 secret key")
|
|
37
|
+
secure: bool = Field(default=False, description="Use HTTPS for S3 connection")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DisplayConfig(BaseSettings):
|
|
41
|
+
"""Display hardware settings."""
|
|
42
|
+
|
|
43
|
+
orientation: Literal["landscape", "portrait"] = Field(default="landscape", description="Display orientation")
|
|
44
|
+
saturation: float = Field(default=0.5, ge=0.0, le=1.0, description="Color saturation for Spectra 6")
|
|
45
|
+
mock: bool = Field(default=False, description="Use mock display for testing without hardware")
|
|
46
|
+
# Only used when mock=True (no hardware to query)
|
|
47
|
+
mock_width: int = Field(default=1600, gt=0, description="Mock display width in pixels")
|
|
48
|
+
mock_height: int = Field(default=1200, gt=0, description="Mock display height in pixels")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Settings(BaseSettings):
|
|
52
|
+
"""Main application settings aggregating all configuration sections."""
|
|
53
|
+
|
|
54
|
+
model_config = SettingsConfigDict(
|
|
55
|
+
env_nested_delimiter="__",
|
|
56
|
+
extra="ignore",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
device: DeviceConfig = Field(default_factory=DeviceConfig)
|
|
60
|
+
api: APIConfig = Field(default_factory=APIConfig)
|
|
61
|
+
s3: S3Config = Field(default_factory=S3Config)
|
|
62
|
+
display: DisplayConfig = Field(default_factory=DisplayConfig)
|
|
63
|
+
|
|
64
|
+
config_file: Path | None = Field(default=None, description="Path to YAML configuration file")
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_yaml(cls, yaml_path: Path) -> "Settings":
|
|
68
|
+
"""Load settings from a YAML configuration file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
yaml_path: Path to the YAML configuration file.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Settings instance with values from YAML merged with env vars.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
with yaml_path.open() as f:
|
|
78
|
+
yaml_config = yaml.safe_load(f) or {}
|
|
79
|
+
|
|
80
|
+
# Build nested config from YAML
|
|
81
|
+
device_config = DeviceConfig(**yaml_config.get("device", {}))
|
|
82
|
+
api_config = APIConfig(**yaml_config.get("api", {}))
|
|
83
|
+
s3_config = S3Config(**yaml_config.get("s3", {}))
|
|
84
|
+
display_config = DisplayConfig(**yaml_config.get("display", {}))
|
|
85
|
+
|
|
86
|
+
return cls(
|
|
87
|
+
device=device_config,
|
|
88
|
+
api=api_config,
|
|
89
|
+
s3=s3_config,
|
|
90
|
+
display=display_config,
|
|
91
|
+
config_file=yaml_path,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_settings(config_path: Path | None = None) -> Settings:
|
|
96
|
+
"""Load application settings from config file and environment variables.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
config_path: Optional path to YAML configuration file.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Settings instance with merged configuration.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
if config_path and config_path.exists():
|
|
106
|
+
return Settings.from_yaml(config_path)
|
|
107
|
+
return Settings()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Main controller orchestrating all display controller components."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from inky_image_display_shared.schemas import (
|
|
7
|
+
DeviceAcknowledge,
|
|
8
|
+
DeviceRegistration,
|
|
9
|
+
DisplayCommand,
|
|
10
|
+
DisplayInfo,
|
|
11
|
+
RegistrationResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from inky_image_display_controller.config import Settings
|
|
15
|
+
from inky_image_display_controller.display import DisplayInterface, create_display
|
|
16
|
+
from inky_image_display_controller.exceptions import CommunicationError, DisplayError
|
|
17
|
+
from inky_image_display_controller.s3_client import S3ImageClient
|
|
18
|
+
from inky_image_display_controller.ws_client import WebSocketClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DisplayController:
|
|
24
|
+
"""Main controller orchestrating display operations.
|
|
25
|
+
|
|
26
|
+
Coordinates WebSocket communication, S3 image fetching, and display updates.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: Settings) -> None:
|
|
30
|
+
"""Initialize the display controller.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
settings: Application settings.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
self._settings = settings
|
|
37
|
+
self._current_image_id: str | None = None
|
|
38
|
+
self._shutdown_event = asyncio.Event()
|
|
39
|
+
|
|
40
|
+
# Initialize components
|
|
41
|
+
self._s3 = S3ImageClient()
|
|
42
|
+
self._display: DisplayInterface = create_display(
|
|
43
|
+
mock=settings.display.mock,
|
|
44
|
+
mock_width=settings.display.mock_width,
|
|
45
|
+
mock_height=settings.display.mock_height,
|
|
46
|
+
)
|
|
47
|
+
self._ws = WebSocketClient(
|
|
48
|
+
api_url=settings.api.url,
|
|
49
|
+
device_id=settings.device.id,
|
|
50
|
+
on_command=self._handle_command,
|
|
51
|
+
on_registration_response=self._handle_registration_response,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Prepare registration payload so it is sent on every (re-)connect
|
|
55
|
+
registration = DeviceRegistration(
|
|
56
|
+
device_id=settings.device.id,
|
|
57
|
+
display=DisplayInfo(
|
|
58
|
+
width=self._display.width,
|
|
59
|
+
height=self._display.height,
|
|
60
|
+
orientation=settings.display.orientation,
|
|
61
|
+
),
|
|
62
|
+
room=settings.device.room,
|
|
63
|
+
)
|
|
64
|
+
self._ws.set_registration_payload(registration.model_dump_json())
|
|
65
|
+
|
|
66
|
+
async def run(self) -> None:
|
|
67
|
+
"""Start all async tasks and run until shutdown.
|
|
68
|
+
|
|
69
|
+
Runs until shutdown is requested via shutdown() method.
|
|
70
|
+
"""
|
|
71
|
+
logger.info("Starting display controller for device: %s", self._settings.device.id)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
async with asyncio.TaskGroup() as tg:
|
|
75
|
+
tg.create_task(self._ws.run(), name="websocket")
|
|
76
|
+
tg.create_task(self._shutdown_monitor(), name="shutdown_monitor")
|
|
77
|
+
except* Exception as eg:
|
|
78
|
+
for exc in eg.exceptions:
|
|
79
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
80
|
+
logger.exception("Task failed: %s", exc)
|
|
81
|
+
finally:
|
|
82
|
+
await self._cleanup()
|
|
83
|
+
|
|
84
|
+
async def shutdown(self) -> None:
|
|
85
|
+
"""Request graceful shutdown."""
|
|
86
|
+
logger.info("Shutdown requested")
|
|
87
|
+
self._shutdown_event.set()
|
|
88
|
+
|
|
89
|
+
async def _shutdown_monitor(self) -> None:
|
|
90
|
+
"""Monitor for shutdown signal and cancel tasks."""
|
|
91
|
+
await self._shutdown_event.wait()
|
|
92
|
+
raise asyncio.CancelledError("Shutdown requested")
|
|
93
|
+
|
|
94
|
+
async def _cleanup(self) -> None:
|
|
95
|
+
"""Clean up resources on shutdown."""
|
|
96
|
+
logger.info("Cleaning up resources...")
|
|
97
|
+
self._s3.close()
|
|
98
|
+
if hasattr(self._display, "close"):
|
|
99
|
+
self._display.close() # ty: ignore[call-non-callable]
|
|
100
|
+
await self._ws.disconnect()
|
|
101
|
+
|
|
102
|
+
async def _handle_registration_response(self, response: RegistrationResponse) -> None:
|
|
103
|
+
"""Process registration response and configure S3 client.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
response: Registration response containing S3 credentials.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
logger.info(
|
|
110
|
+
"Received registration response: status=%s, endpoint=%s",
|
|
111
|
+
response.status,
|
|
112
|
+
response.s3_endpoint,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
self._s3.configure(
|
|
116
|
+
endpoint=response.s3_endpoint,
|
|
117
|
+
access_key=response.s3_access_key,
|
|
118
|
+
secret_key=response.s3_secret_key,
|
|
119
|
+
bucket=response.s3_bucket,
|
|
120
|
+
secure=response.s3_secure,
|
|
121
|
+
region=response.s3_region,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def _handle_command(self, command: DisplayCommand) -> None:
|
|
125
|
+
"""Process incoming display commands.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
command: Command to process.
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
logger.info("Received command: action=%s, image_id=%s", command.action, command.image_id)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
match command.action:
|
|
135
|
+
case "display":
|
|
136
|
+
await self._handle_display(command)
|
|
137
|
+
case "clear":
|
|
138
|
+
await self._handle_clear()
|
|
139
|
+
case "status":
|
|
140
|
+
await self._send_acknowledge(success=True)
|
|
141
|
+
case _:
|
|
142
|
+
logger.warning("Unknown command action: %s", command.action)
|
|
143
|
+
await self._send_acknowledge(
|
|
144
|
+
image_id=command.image_id,
|
|
145
|
+
success=False,
|
|
146
|
+
error=f"Unknown action: {command.action}",
|
|
147
|
+
)
|
|
148
|
+
except (CommunicationError, DisplayError) as e:
|
|
149
|
+
logger.exception("Command failed: %s", command.action)
|
|
150
|
+
await self._send_acknowledge(
|
|
151
|
+
image_id=command.image_id,
|
|
152
|
+
success=False,
|
|
153
|
+
error=str(e),
|
|
154
|
+
)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.exception("Unexpected error handling command")
|
|
157
|
+
await self._send_acknowledge(
|
|
158
|
+
image_id=command.image_id,
|
|
159
|
+
success=False,
|
|
160
|
+
error=f"Unexpected error: {e}",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def _handle_display(self, command: DisplayCommand) -> None:
|
|
164
|
+
"""Fetch and display an image.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
command: Display command with image path.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If image_path or image_id is missing.
|
|
171
|
+
CommunicationError: If S3 fetch fails.
|
|
172
|
+
DisplayError: If display update fails.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
if not command.image_path or not command.image_id:
|
|
176
|
+
raise ValueError("display command requires image_path and image_id")
|
|
177
|
+
|
|
178
|
+
if not self._s3.is_configured:
|
|
179
|
+
raise CommunicationError("S3 not configured - awaiting registration")
|
|
180
|
+
|
|
181
|
+
logger.info("Fetching image: %s", command.image_path)
|
|
182
|
+
image = await self._s3.fetch_image(command.image_path)
|
|
183
|
+
|
|
184
|
+
logger.info("Displaying image: %s", command.image_id)
|
|
185
|
+
await self._display.show_image(
|
|
186
|
+
image,
|
|
187
|
+
saturation=self._settings.display.saturation,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self._current_image_id = command.image_id
|
|
191
|
+
|
|
192
|
+
await self._send_acknowledge(
|
|
193
|
+
image_id=command.image_id,
|
|
194
|
+
success=True,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def _handle_clear(self) -> None:
|
|
198
|
+
"""Clear the display.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
DisplayError: If display clear fails.
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
logger.info("Clearing display")
|
|
205
|
+
await self._display.clear()
|
|
206
|
+
self._current_image_id = None
|
|
207
|
+
|
|
208
|
+
await self._send_acknowledge(success=True)
|
|
209
|
+
|
|
210
|
+
async def _send_acknowledge(
|
|
211
|
+
self,
|
|
212
|
+
image_id: str | None = None,
|
|
213
|
+
success: bool = True,
|
|
214
|
+
error: str | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Send acknowledgment after command processing.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
image_id: Image ID if applicable.
|
|
220
|
+
success: Whether the command was successful.
|
|
221
|
+
error: Error message if command failed.
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
acknowledge = DeviceAcknowledge(
|
|
225
|
+
device_id=self._settings.device.id,
|
|
226
|
+
image_id=image_id or self._current_image_id,
|
|
227
|
+
successful_display_change=success,
|
|
228
|
+
error=error,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
await self._ws.send_acknowledge(acknowledge)
|
|
233
|
+
except Exception:
|
|
234
|
+
logger.exception("Failed to send acknowledgment")
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from inky_image_display_controller.exceptions import DisplayError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DisplayInterface(ABC):
|
|
16
|
+
"""Abstract interface for display implementations."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def width(self) -> int:
|
|
21
|
+
"""Display width in pixels."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def height(self) -> int:
|
|
26
|
+
"""Display height in pixels."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
30
|
+
"""Display an image on the screen.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
image: PIL Image to display.
|
|
34
|
+
saturation: Color saturation for Spectra 6 displays (0.0-1.0).
|
|
35
|
+
|
|
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
|
+
The Inky library itself handles any internal rotation required by the
|
|
51
|
+
physical mounting — the public API always expects landscape images
|
|
52
|
+
(wider than tall).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
executor: ThreadPoolExecutor | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the Inky display wrapper.
|
|
60
|
+
|
|
61
|
+
Connects to hardware immediately to detect display dimensions.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
executor: Optional thread pool executor for display operations.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
DisplayError: If the display cannot be initialized.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="inky")
|
|
71
|
+
self._lock = asyncio.Lock()
|
|
72
|
+
|
|
73
|
+
# Eager init - get dimensions from hardware
|
|
74
|
+
try:
|
|
75
|
+
from inky.auto import auto # ty: ignore[unresolved-import] # noqa: PLC0415
|
|
76
|
+
|
|
77
|
+
self._display = auto()
|
|
78
|
+
self._width: int = self._display.width
|
|
79
|
+
self._height: int = self._display.height
|
|
80
|
+
logger.info("Inky display initialized: %dx%d", self._width, self._height)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.exception("Failed to initialize Inky display")
|
|
83
|
+
raise DisplayError(f"Failed to initialize display: {e}") from e
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def width(self) -> int:
|
|
87
|
+
"""Display width in pixels (hardware landscape dimension)."""
|
|
88
|
+
return self._width
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def height(self) -> int:
|
|
92
|
+
"""Display height in pixels (hardware landscape dimension)."""
|
|
93
|
+
return self._height
|
|
94
|
+
|
|
95
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
96
|
+
"""Display an image on the Inky screen.
|
|
97
|
+
|
|
98
|
+
Runs the blocking display update in a thread pool.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
image: PIL Image to display.
|
|
102
|
+
saturation: Color saturation (0.0-1.0).
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
DisplayError: If the display update fails.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
async with self._lock:
|
|
109
|
+
loop = asyncio.get_event_loop()
|
|
110
|
+
try:
|
|
111
|
+
await loop.run_in_executor(
|
|
112
|
+
self._executor,
|
|
113
|
+
self._show_image_sync,
|
|
114
|
+
image,
|
|
115
|
+
saturation,
|
|
116
|
+
)
|
|
117
|
+
except DisplayError:
|
|
118
|
+
raise
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.exception("Failed to update display")
|
|
121
|
+
raise DisplayError(f"Display update failed: {e}") from e
|
|
122
|
+
|
|
123
|
+
def _show_image_sync(self, image: Image.Image, saturation: float) -> None:
|
|
124
|
+
"""Perform synchronous display update.
|
|
125
|
+
|
|
126
|
+
This method blocks for ~20-25 seconds during the e-ink refresh.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
image: PIL Image to display.
|
|
130
|
+
saturation: Color saturation (0.0-1.0).
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
DisplayError: If image dimensions don't match display dimensions.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
# Normalise to landscape: the Inky library always expects the wider
|
|
137
|
+
# dimension as width, and handles any physical mounting rotation
|
|
138
|
+
# internally (hard-coded rot90 in InkyEL133UF1.show()).
|
|
139
|
+
if image.height > image.width:
|
|
140
|
+
image = image.transpose(Image.Transpose.ROTATE_90)
|
|
141
|
+
|
|
142
|
+
if image.size != (self.width, self.height):
|
|
143
|
+
raise DisplayError(
|
|
144
|
+
f"Image size {image.size[0]}x{image.size[1]} does not match display size {self.width}x{self.height}."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
display_image = image
|
|
148
|
+
|
|
149
|
+
logger.info("Updating display (this takes ~20-25 seconds)...")
|
|
150
|
+
self._display.set_image(display_image, saturation=saturation)
|
|
151
|
+
try:
|
|
152
|
+
self._display.show(busy_wait=True)
|
|
153
|
+
except FileNotFoundError as e:
|
|
154
|
+
raise DisplayError("SPI device not found. Ensure SPI is enabled via raspi-config and reboot.") from e
|
|
155
|
+
except PermissionError as e:
|
|
156
|
+
raise DisplayError("Permission denied accessing SPI device. Add user to 'spi' group and re-login.") from e
|
|
157
|
+
logger.info("Display update complete")
|
|
158
|
+
|
|
159
|
+
async def clear(self) -> None:
|
|
160
|
+
"""Clear the display to white."""
|
|
161
|
+
white_image = Image.new("RGB", (self.width, self.height), (255, 255, 255))
|
|
162
|
+
await self.show_image(white_image)
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Clean up resources."""
|
|
166
|
+
if self._executor:
|
|
167
|
+
self._executor.shutdown(wait=False)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class MockDisplay(DisplayInterface):
|
|
171
|
+
"""Mock display for testing without hardware.
|
|
172
|
+
|
|
173
|
+
Stores the last displayed image for inspection in tests.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, width: int = 1600, height: int = 1200) -> None:
|
|
177
|
+
"""Initialize the mock display.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
width: Simulated display width.
|
|
181
|
+
height: Simulated display height.
|
|
182
|
+
|
|
183
|
+
"""
|
|
184
|
+
self._width = width
|
|
185
|
+
self._height = height
|
|
186
|
+
self._last_image: Image.Image | None = None
|
|
187
|
+
self._display_count = 0
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def width(self) -> int:
|
|
191
|
+
"""Display width in pixels."""
|
|
192
|
+
return self._width
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def height(self) -> int:
|
|
196
|
+
"""Display height in pixels."""
|
|
197
|
+
return self._height
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def last_image(self) -> Image.Image | None:
|
|
201
|
+
"""The last image that was displayed."""
|
|
202
|
+
return self._last_image
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def display_count(self) -> int:
|
|
206
|
+
"""Number of times show_image was called."""
|
|
207
|
+
return self._display_count
|
|
208
|
+
|
|
209
|
+
async def show_image(self, image: Image.Image, saturation: float = 0.5) -> None:
|
|
210
|
+
"""Store the image for inspection.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
image: PIL Image to "display".
|
|
214
|
+
saturation: Color saturation (ignored in mock).
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
DisplayError: If image dimensions don't match display dimensions.
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
_ = saturation # Unused in mock, but part of interface
|
|
221
|
+
|
|
222
|
+
# Normalise to landscape, same as the real display
|
|
223
|
+
if image.height > image.width:
|
|
224
|
+
image = image.transpose(Image.Transpose.ROTATE_90)
|
|
225
|
+
|
|
226
|
+
if image.size != (self._width, self._height):
|
|
227
|
+
raise DisplayError(
|
|
228
|
+
f"Image size {image.size[0]}x{image.size[1]} does not match display size {self._width}x{self._height}."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
self._last_image = image.copy()
|
|
232
|
+
self._display_count += 1
|
|
233
|
+
logger.debug("Mock display: stored image %dx%d", image.width, image.height)
|
|
234
|
+
# Simulate a brief delay (real display takes ~25s)
|
|
235
|
+
await asyncio.sleep(0.1)
|
|
236
|
+
|
|
237
|
+
async def clear(self) -> None:
|
|
238
|
+
"""Clear the mock display."""
|
|
239
|
+
self._last_image = None
|
|
240
|
+
self._display_count += 1
|
|
241
|
+
logger.debug("Mock display: cleared")
|
|
242
|
+
await asyncio.sleep(0.1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def create_display(
|
|
246
|
+
mock: bool = False,
|
|
247
|
+
mock_width: int = 1600,
|
|
248
|
+
mock_height: int = 1200,
|
|
249
|
+
) -> DisplayInterface:
|
|
250
|
+
"""Create the appropriate display implementation.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
mock: If True, create a MockDisplay for testing.
|
|
254
|
+
mock_width: Width for mock display (ignored for real hardware).
|
|
255
|
+
mock_height: Height for mock display (ignored for real hardware).
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
DisplayInterface implementation.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
DisplayError: If real hardware initialization fails.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
if mock:
|
|
265
|
+
logger.info("Creating mock display (%dx%d)", mock_width, mock_height)
|
|
266
|
+
return MockDisplay(width=mock_width, height=mock_height)
|
|
267
|
+
|
|
268
|
+
logger.info("Creating Inky display")
|
|
269
|
+
return InkyDisplay()
|
|
@@ -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 S3 communication fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DisplayError(DisplayControllerError):
|
|
17
|
+
"""Raised when a display hardware operation fails."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""CLI entry point for the Inky Display Controller."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from inky_image_display_controller.config import load_settings
|
|
13
|
+
from inky_image_display_controller.controller import DisplayController
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="inky-controller",
|
|
17
|
+
help="Inky Display Controller - E-ink display management daemon for Raspberry Pi.",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
23
|
+
"""Configure logging for the application.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
verbose: If True, set log level to DEBUG.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=level,
|
|
32
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
33
|
+
handlers=[logging.StreamHandler(sys.stdout)],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def main(
|
|
39
|
+
config: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--config",
|
|
43
|
+
"-c",
|
|
44
|
+
help="Path to YAML configuration file.",
|
|
45
|
+
exists=True,
|
|
46
|
+
dir_okay=False,
|
|
47
|
+
resolve_path=True,
|
|
48
|
+
),
|
|
49
|
+
] = None,
|
|
50
|
+
device_id: Annotated[
|
|
51
|
+
str | None,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--device-id",
|
|
54
|
+
"-d",
|
|
55
|
+
help="Device identifier (overrides config file).",
|
|
56
|
+
envvar="DEVICE_ID",
|
|
57
|
+
),
|
|
58
|
+
] = None,
|
|
59
|
+
verbose: Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--verbose",
|
|
63
|
+
"-v",
|
|
64
|
+
help="Enable verbose debug logging.",
|
|
65
|
+
),
|
|
66
|
+
] = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Start the Inky Display Controller daemon.
|
|
69
|
+
|
|
70
|
+
The controller connects to the Inky Image Display API via WebSocket,
|
|
71
|
+
registers with the server, and displays images received via commands.
|
|
72
|
+
"""
|
|
73
|
+
setup_logging(verbose=verbose)
|
|
74
|
+
logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
# Load settings
|
|
77
|
+
settings = load_settings(config)
|
|
78
|
+
|
|
79
|
+
# Override from CLI arguments
|
|
80
|
+
if device_id:
|
|
81
|
+
settings.device.id = device_id
|
|
82
|
+
|
|
83
|
+
logger.info("Starting Inky Display Controller")
|
|
84
|
+
logger.info("Device ID: %s", settings.device.id)
|
|
85
|
+
logger.info("API URL: %s", settings.api.url)
|
|
86
|
+
logger.info("Mock display: %s", settings.display.mock)
|
|
87
|
+
|
|
88
|
+
# Create controller
|
|
89
|
+
controller = DisplayController(settings)
|
|
90
|
+
|
|
91
|
+
# Setup signal handlers for graceful shutdown
|
|
92
|
+
loop = asyncio.new_event_loop()
|
|
93
|
+
asyncio.set_event_loop(loop)
|
|
94
|
+
shutdown_task: asyncio.Task[None] | None = None
|
|
95
|
+
|
|
96
|
+
def signal_handler(sig: signal.Signals) -> None:
|
|
97
|
+
nonlocal shutdown_task
|
|
98
|
+
logger.info("Received signal %s, initiating shutdown...", sig.name)
|
|
99
|
+
shutdown_task = loop.create_task(controller.shutdown())
|
|
100
|
+
|
|
101
|
+
# Register signal handlers
|
|
102
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
103
|
+
loop.add_signal_handler(sig, signal_handler, sig)
|
|
104
|
+
|
|
105
|
+
_ = shutdown_task # Reference to prevent unused variable warning
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
loop.run_until_complete(controller.run())
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
logger.info("Interrupted by user")
|
|
111
|
+
finally:
|
|
112
|
+
# Cancel all remaining tasks
|
|
113
|
+
pending = asyncio.all_tasks(loop)
|
|
114
|
+
for task in pending:
|
|
115
|
+
task.cancel()
|
|
116
|
+
|
|
117
|
+
# Wait for cancellation
|
|
118
|
+
if pending:
|
|
119
|
+
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
120
|
+
|
|
121
|
+
loop.close()
|
|
122
|
+
logger.info("Display controller stopped")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""S3/MinIO client for fetching images from object storage."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from minio import Minio
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from inky_image_display_controller.exceptions import CommunicationError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class S3ImageClient:
|
|
18
|
+
"""Client for fetching images from S3-compatible object storage.
|
|
19
|
+
|
|
20
|
+
The MinIO SDK is synchronous, so operations are wrapped with
|
|
21
|
+
run_in_executor to avoid blocking the async event loop.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, executor: ThreadPoolExecutor | None = None) -> None:
|
|
25
|
+
"""Initialize the S3 client.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
executor: Optional thread pool executor for async operations.
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
self._client: Minio | None = None
|
|
32
|
+
self._bucket: str | None = None
|
|
33
|
+
self._executor = executor or ThreadPoolExecutor(max_workers=2)
|
|
34
|
+
|
|
35
|
+
def configure( # noqa: PLR0913
|
|
36
|
+
self,
|
|
37
|
+
endpoint: str,
|
|
38
|
+
access_key: str,
|
|
39
|
+
secret_key: str,
|
|
40
|
+
bucket: str,
|
|
41
|
+
secure: bool = False,
|
|
42
|
+
region: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Configure the S3 client with credentials.
|
|
45
|
+
|
|
46
|
+
Typically called after receiving RegistrationResponse from MQTT.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
endpoint: S3 server endpoint (host:port).
|
|
50
|
+
access_key: S3 access key.
|
|
51
|
+
secret_key: S3 secret key.
|
|
52
|
+
bucket: Bucket name containing images.
|
|
53
|
+
secure: Whether to use HTTPS.
|
|
54
|
+
region: Optional S3 region. Falls back to the client library default when None.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
kwargs: dict[str, Any] = {
|
|
58
|
+
"access_key": access_key,
|
|
59
|
+
"secret_key": secret_key,
|
|
60
|
+
"secure": secure,
|
|
61
|
+
}
|
|
62
|
+
if region is not None:
|
|
63
|
+
kwargs["region"] = region
|
|
64
|
+
self._client = Minio(endpoint, **kwargs)
|
|
65
|
+
self._bucket = bucket
|
|
66
|
+
logger.info("S3 client configured for endpoint: %s, bucket: %s", endpoint, bucket)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_configured(self) -> bool:
|
|
70
|
+
"""Check if the client has been configured with credentials."""
|
|
71
|
+
return self._client is not None and self._bucket is not None
|
|
72
|
+
|
|
73
|
+
async def fetch_image(self, object_path: str) -> Image.Image:
|
|
74
|
+
"""Fetch an image from S3 storage and return as PIL Image.
|
|
75
|
+
|
|
76
|
+
Runs the synchronous MinIO SDK operation in a thread pool
|
|
77
|
+
to avoid blocking the async event loop.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
object_path: Path to the object in the S3 bucket.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
PIL Image object.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
CommunicationError: If the S3 client is not configured or fetch fails.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
if not self.is_configured:
|
|
90
|
+
raise CommunicationError("S3 client not configured. Await registration.")
|
|
91
|
+
|
|
92
|
+
loop = asyncio.get_event_loop()
|
|
93
|
+
try:
|
|
94
|
+
return await loop.run_in_executor(
|
|
95
|
+
self._executor,
|
|
96
|
+
self._fetch_image_sync,
|
|
97
|
+
object_path,
|
|
98
|
+
)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.exception("Failed to fetch image from S3: %s", object_path)
|
|
101
|
+
raise CommunicationError(f"Failed to fetch image: {e}") from e
|
|
102
|
+
|
|
103
|
+
def _fetch_image_sync(self, object_path: str) -> Image.Image:
|
|
104
|
+
"""Fetch image data from S3 synchronously.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
object_path: Path to the object in the S3 bucket.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
PIL Image object.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
assert self._client is not None
|
|
114
|
+
assert self._bucket is not None
|
|
115
|
+
|
|
116
|
+
logger.debug("Fetching image from S3: %s/%s", self._bucket, object_path)
|
|
117
|
+
response = self._client.get_object(self._bucket, object_path)
|
|
118
|
+
try:
|
|
119
|
+
image_data = BytesIO(response.read())
|
|
120
|
+
image = Image.open(image_data)
|
|
121
|
+
# Load the image data into memory so we can close the response
|
|
122
|
+
image.load()
|
|
123
|
+
logger.debug("Successfully fetched image: %s (size: %s)", object_path, image.size)
|
|
124
|
+
return image
|
|
125
|
+
finally:
|
|
126
|
+
response.close()
|
|
127
|
+
response.release_conn()
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
"""Clean up resources."""
|
|
131
|
+
if self._executor:
|
|
132
|
+
self._executor.shutdown(wait=False)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""WebSocket client for device communication with the API server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
import websockets
|
|
8
|
+
from inky_image_display_shared.schemas import (
|
|
9
|
+
DeviceAcknowledge,
|
|
10
|
+
DisplayCommand,
|
|
11
|
+
RegistrationResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
CommandHandler = Callable[[DisplayCommand], Awaitable[None]]
|
|
17
|
+
RegistrationHandler = Callable[[RegistrationResponse], Awaitable[None]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebSocketClient:
|
|
21
|
+
"""WebSocket client that connects to the API and handles device communication.
|
|
22
|
+
|
|
23
|
+
Replaces the MQTT-based transport. The lifecycle is:
|
|
24
|
+
1. Connect to ``ws://{api_url}/ws/devices/{device_id}``.
|
|
25
|
+
2. Send ``DeviceRegistration`` JSON.
|
|
26
|
+
3. First response is ``RegistrationResponse`` → call ``on_registration_response``.
|
|
27
|
+
4. All subsequent messages are ``DisplayCommand`` → call ``on_command``.
|
|
28
|
+
5. On disconnect: exponential backoff reconnect.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_url: str,
|
|
34
|
+
device_id: str,
|
|
35
|
+
on_command: CommandHandler,
|
|
36
|
+
on_registration_response: RegistrationHandler,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the WebSocket client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_url: API base URL (e.g. ``ws://localhost:8000``).
|
|
42
|
+
device_id: Device identifier for the WebSocket path.
|
|
43
|
+
on_command: Callback for incoming display commands.
|
|
44
|
+
on_registration_response: Callback for the registration response.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
self._api_url = api_url.rstrip("/")
|
|
48
|
+
self._device_id = device_id
|
|
49
|
+
self._on_command = on_command
|
|
50
|
+
self._on_registration_response = on_registration_response
|
|
51
|
+
self._ws: websockets.ClientConnection | None = None
|
|
52
|
+
self._connected = asyncio.Event()
|
|
53
|
+
self._registration: str | None = None # JSON payload set before run()
|
|
54
|
+
|
|
55
|
+
def set_registration_payload(self, payload_json: str) -> None:
|
|
56
|
+
"""Store the registration JSON to send on (re-)connect.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
payload_json: Serialised ``DeviceRegistration``.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
self._registration = payload_json
|
|
63
|
+
|
|
64
|
+
async def run(self) -> None:
|
|
65
|
+
"""Connect, register, and listen for commands. Auto-reconnects on failure."""
|
|
66
|
+
reconnect_interval = 5
|
|
67
|
+
max_reconnect_interval = 60
|
|
68
|
+
ws_url = f"{self._api_url}/ws/devices/{self._device_id}"
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
async with websockets.connect(ws_url) as ws:
|
|
73
|
+
self._ws = ws
|
|
74
|
+
self._connected.set()
|
|
75
|
+
logger.info("Connected to API at %s", ws_url)
|
|
76
|
+
|
|
77
|
+
# Reset backoff on successful connection
|
|
78
|
+
reconnect_interval = 5
|
|
79
|
+
|
|
80
|
+
# Send registration
|
|
81
|
+
if self._registration is not None:
|
|
82
|
+
await ws.send(self._registration)
|
|
83
|
+
logger.info("Sent registration for device %s", self._device_id)
|
|
84
|
+
|
|
85
|
+
# First message back is the RegistrationResponse
|
|
86
|
+
raw = await ws.recv()
|
|
87
|
+
if isinstance(raw, bytes):
|
|
88
|
+
raw = raw.decode("utf-8")
|
|
89
|
+
response = RegistrationResponse.model_validate_json(raw)
|
|
90
|
+
await self._on_registration_response(response)
|
|
91
|
+
|
|
92
|
+
# Listen for commands
|
|
93
|
+
async for raw_msg in ws:
|
|
94
|
+
text = raw_msg.decode("utf-8") if isinstance(raw_msg, bytes) else raw_msg
|
|
95
|
+
try:
|
|
96
|
+
command = DisplayCommand.model_validate_json(text)
|
|
97
|
+
await self._on_command(command)
|
|
98
|
+
except Exception:
|
|
99
|
+
logger.exception("Error handling message: %s", text[:200])
|
|
100
|
+
|
|
101
|
+
except (
|
|
102
|
+
websockets.ConnectionClosed,
|
|
103
|
+
OSError,
|
|
104
|
+
TimeoutError,
|
|
105
|
+
) as e:
|
|
106
|
+
self._connected.clear()
|
|
107
|
+
self._ws = None
|
|
108
|
+
logger.warning(
|
|
109
|
+
"WebSocket connection lost: %s. Reconnecting in %d seconds...",
|
|
110
|
+
e,
|
|
111
|
+
reconnect_interval,
|
|
112
|
+
)
|
|
113
|
+
await asyncio.sleep(reconnect_interval)
|
|
114
|
+
reconnect_interval = min(reconnect_interval * 2, max_reconnect_interval)
|
|
115
|
+
|
|
116
|
+
async def send_acknowledge(self, acknowledge: DeviceAcknowledge) -> None:
|
|
117
|
+
"""Send acknowledgment JSON over the WebSocket.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
acknowledge: Acknowledgment payload.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
async with asyncio.timeout(30.0):
|
|
125
|
+
await self._connected.wait()
|
|
126
|
+
except TimeoutError:
|
|
127
|
+
raise RuntimeError("WebSocket connection timeout") from None
|
|
128
|
+
|
|
129
|
+
if self._ws is not None:
|
|
130
|
+
await self._ws.send(acknowledge.model_dump_json())
|
|
131
|
+
logger.debug(
|
|
132
|
+
"Sent acknowledgment: success=%s, image_id=%s",
|
|
133
|
+
acknowledge.successful_display_change,
|
|
134
|
+
acknowledge.image_id,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def disconnect(self) -> None:
|
|
138
|
+
"""Close the WebSocket connection."""
|
|
139
|
+
self._connected.clear()
|
|
140
|
+
if self._ws is not None:
|
|
141
|
+
await self._ws.close()
|
|
142
|
+
self._ws = None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inky-image-display-controller
|
|
3
|
+
Version: 0.14.0
|
|
4
|
+
Summary: Device-side controller for Inky e-ink picture displays.
|
|
5
|
+
Author: stkr22
|
|
6
|
+
Author-email: stkr22 <stkr22@github.com>
|
|
7
|
+
License-Expression: GPL-3.0-only
|
|
8
|
+
Requires-Dist: inky-image-display-shared
|
|
9
|
+
Requires-Dist: websockets~=16.0.0
|
|
10
|
+
Requires-Dist: minio~=7.2.20
|
|
11
|
+
Requires-Dist: pillow~=12.2.0
|
|
12
|
+
Requires-Dist: pydantic~=2.12.5
|
|
13
|
+
Requires-Dist: pydantic-settings~=2.13.1
|
|
14
|
+
Requires-Dist: pyyaml~=6.0.3
|
|
15
|
+
Requires-Dist: typer~=0.24.1
|
|
16
|
+
Requires-Dist: inky~=2.4.0 ; extra == 'rpi'
|
|
17
|
+
Requires-Python: >=3.12, <3.14
|
|
18
|
+
Provides-Extra: rpi
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
inky_image_display_controller/__init__.py,sha256=huQUfIIHA7tAIRTQ5-DBcSaoxdkF5SU_4zYJxRt0oIQ,1028
|
|
2
|
+
inky_image_display_controller/config.py,sha256=_2O6fpZYRQpb_GJH7BCsfw0QCZ5avLebr-8pj3NgSew,3934
|
|
3
|
+
inky_image_display_controller/controller.py,sha256=K_izyWaKTGNnAvkf7yd-H8H026IZfCZfbBrXcB0KqPI,7956
|
|
4
|
+
inky_image_display_controller/display.py,sha256=z5FrLpnkyLpWEKPswVcQS_2hSf8Um4PcdfHsqJCDIbU,8695
|
|
5
|
+
inky_image_display_controller/exceptions.py,sha256=M0KP0mUgf_mUL_V5Xs3snY3r5IRq5M0YlQCjuTkxYog,485
|
|
6
|
+
inky_image_display_controller/main.py,sha256=1x-5R6TWiKQ-QUqIPAHJYN6Zq2tIOsShDY9ZPd5kppE,3449
|
|
7
|
+
inky_image_display_controller/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
inky_image_display_controller/s3_client.py,sha256=STbAFGQfp3iiuj5pd_ZXhR1gXf0OmFJDbGJWYE9tHN0,4267
|
|
9
|
+
inky_image_display_controller/ws_client.py,sha256=SWNXtg4ibE_zKGSZGDvRLahsmGdDY2ro541-KAqofEI,5296
|
|
10
|
+
inky_image_display_controller-0.14.0.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
|
|
11
|
+
inky_image_display_controller-0.14.0.dist-info/entry_points.txt,sha256=EuHhjcTCob8ozXeOhrkHU6AeOL4fEiGq1p0UQVSIzt4,90
|
|
12
|
+
inky_image_display_controller-0.14.0.dist-info/METADATA,sha256=nBr8wAFD2fEaLUrg9c2mzN37MCCb67TeJHP0wupaXsw,588
|
|
13
|
+
inky_image_display_controller-0.14.0.dist-info/RECORD,,
|