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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ inky-image-display-controller = inky_image_display_controller.main:app
3
+