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.
@@ -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."""