private-assistant-picture-display-skill 0.4.1__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.
Files changed (32) hide show
  1. private_assistant_picture_display_skill/__init__.py +15 -0
  2. private_assistant_picture_display_skill/config.py +61 -0
  3. private_assistant_picture_display_skill/immich/__init__.py +20 -0
  4. private_assistant_picture_display_skill/immich/client.py +250 -0
  5. private_assistant_picture_display_skill/immich/config.py +94 -0
  6. private_assistant_picture_display_skill/immich/models.py +109 -0
  7. private_assistant_picture_display_skill/immich/payloads.py +55 -0
  8. private_assistant_picture_display_skill/immich/storage.py +127 -0
  9. private_assistant_picture_display_skill/immich/sync_service.py +501 -0
  10. private_assistant_picture_display_skill/main.py +152 -0
  11. private_assistant_picture_display_skill/models/__init__.py +24 -0
  12. private_assistant_picture_display_skill/models/commands.py +63 -0
  13. private_assistant_picture_display_skill/models/device.py +30 -0
  14. private_assistant_picture_display_skill/models/image.py +62 -0
  15. private_assistant_picture_display_skill/models/immich_sync_job.py +109 -0
  16. private_assistant_picture_display_skill/picture_skill.py +575 -0
  17. private_assistant_picture_display_skill/py.typed +0 -0
  18. private_assistant_picture_display_skill/services/__init__.py +9 -0
  19. private_assistant_picture_display_skill/services/device_mqtt_client.py +163 -0
  20. private_assistant_picture_display_skill/services/image_manager.py +175 -0
  21. private_assistant_picture_display_skill/templates/describe_image.j2 +11 -0
  22. private_assistant_picture_display_skill/templates/help.j2 +1 -0
  23. private_assistant_picture_display_skill/templates/next_picture.j2 +9 -0
  24. private_assistant_picture_display_skill/utils/__init__.py +15 -0
  25. private_assistant_picture_display_skill/utils/color_analysis.py +78 -0
  26. private_assistant_picture_display_skill/utils/image_processing.py +104 -0
  27. private_assistant_picture_display_skill/utils/metadata_builder.py +135 -0
  28. private_assistant_picture_display_skill-0.4.1.dist-info/METADATA +47 -0
  29. private_assistant_picture_display_skill-0.4.1.dist-info/RECORD +32 -0
  30. private_assistant_picture_display_skill-0.4.1.dist-info/WHEEL +4 -0
  31. private_assistant_picture_display_skill-0.4.1.dist-info/entry_points.txt +3 -0
  32. private_assistant_picture_display_skill-0.4.1.dist-info/licenses/LICENSE +0 -0
@@ -0,0 +1,575 @@
1
+ """Picture Display Skill for controlling Inky e-ink displays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING
8
+
9
+ import jinja2
10
+ from private_assistant_commons import BaseSkill, IntentRequest, IntentType
11
+ from private_assistant_commons.database.models import GlobalDevice
12
+ from pydantic import ValidationError
13
+ from sqlmodel import select
14
+ from sqlmodel.ext.asyncio.session import AsyncSession
15
+
16
+ if TYPE_CHECKING:
17
+ import logging
18
+ from uuid import UUID
19
+
20
+ import aiomqtt
21
+ from sqlalchemy.ext.asyncio import AsyncEngine
22
+
23
+ from private_assistant_picture_display_skill.config import DeviceMqttConfig, MinioConfig, PictureSkillConfig
24
+ from private_assistant_picture_display_skill.models.commands import (
25
+ DeviceAcknowledge,
26
+ DeviceRegistration,
27
+ RegistrationResponse,
28
+ )
29
+ from private_assistant_picture_display_skill.models.device import DeviceDisplayState
30
+ from private_assistant_picture_display_skill.services.device_mqtt_client import DeviceMqttClient
31
+ from private_assistant_picture_display_skill.services.image_manager import ImageManager
32
+
33
+
34
+ class PictureSkill(BaseSkill):
35
+ """Voice-controlled picture display skill for Inky e-ink devices.
36
+
37
+ Handles voice commands to control image display on Inky devices:
38
+ - "next picture" / "show next" - Display next image in queue
39
+ - "what am I seeing?" / "describe this picture" - Describe current image
40
+ - "help with pictures" - Get help text
41
+
42
+ The skill connects to two MQTT brokers:
43
+ 1. Internal MQTT (via BaseSkill): For intent engine communication
44
+ 2. Device MQTT (authenticated): For Inky device communication
45
+ """
46
+
47
+ # Class attribute for rotation check interval (seconds) - can be overridden in tests
48
+ rotation_check_interval: int = 30
49
+
50
+ def __init__( # noqa: PLR0913
51
+ self,
52
+ config_obj: PictureSkillConfig,
53
+ mqtt_client: aiomqtt.Client,
54
+ task_group: asyncio.TaskGroup,
55
+ engine: AsyncEngine,
56
+ logger: logging.Logger | None = None,
57
+ template_env: jinja2.Environment | None = None,
58
+ ) -> None:
59
+ """Initialize the Picture Display Skill.
60
+
61
+ Args:
62
+ config_obj: Skill configuration (inherits from SkillConfig)
63
+ mqtt_client: Internal MQTT client (from BaseSkill)
64
+ task_group: Asyncio task group for concurrent operations
65
+ engine: Async database engine
66
+ logger: Optional custom logger
67
+ template_env: Jinja2 template environment for voice responses
68
+ """
69
+ super().__init__(
70
+ config_obj=config_obj,
71
+ mqtt_client=mqtt_client,
72
+ task_group=task_group,
73
+ engine=engine,
74
+ certainty_threshold=0.7,
75
+ logger=logger,
76
+ )
77
+
78
+ # Store skill-specific config
79
+ self.skill_config = config_obj
80
+
81
+ # Load device MQTT and MinIO configs from environment
82
+ # AIDEV-NOTE: These use pydantic-settings with env prefixes (DEVICE_MQTT_*, MINIO_*)
83
+ self.device_mqtt_config = DeviceMqttConfig()
84
+ self.minio_config = MinioConfig()
85
+
86
+ # Configure supported intents with confidence thresholds
87
+ self.supported_intents = {
88
+ IntentType.MEDIA_NEXT: 0.8, # "next picture", "show next", "skip"
89
+ IntentType.QUERY_STATUS: 0.7, # "what am I seeing?", "describe this"
90
+ IntentType.SYSTEM_HELP: 0.7, # "help with pictures"
91
+ }
92
+
93
+ # Device type for global registry
94
+ self.supported_device_types = ["picture_display"]
95
+
96
+ # Use provided template environment or create default
97
+ if template_env is not None:
98
+ self.template_env = template_env
99
+ else:
100
+ self.template_env = jinja2.Environment(
101
+ loader=jinja2.PackageLoader("private_assistant_picture_display_skill", "templates"),
102
+ autoescape=True,
103
+ )
104
+
105
+ # Services - initialized in skill_preparations
106
+ self.device_mqtt: DeviceMqttClient | None = None
107
+ self.image_manager: ImageManager | None = None
108
+
109
+ async def skill_preparations(self) -> None:
110
+ """Initialize services after MQTT setup."""
111
+ await super().skill_preparations()
112
+
113
+ # Initialize device MQTT client
114
+ self.device_mqtt = DeviceMqttClient(self.device_mqtt_config, self.logger)
115
+
116
+ # Start device MQTT as background task
117
+ # AIDEV-NOTE: mqtt_connection_handler doesn't know about our second MQTT, so we manage it here
118
+ self.add_task(self.start_device_mqtt(), name="device_mqtt")
119
+
120
+ self.logger.info("Picture skill preparations complete")
121
+
122
+ async def start_device_mqtt(self) -> None:
123
+ """Start the device MQTT connection and message listener.
124
+
125
+ Runs the device MQTT connection as a background task with
126
+ auto-reconnect behavior.
127
+ """
128
+ if self.device_mqtt is None:
129
+ raise RuntimeError("Device MQTT client not initialized")
130
+
131
+ async with self.device_mqtt.connect():
132
+ # Initialize services that depend on MQTT
133
+ self.image_manager = ImageManager(
134
+ engine=self.engine,
135
+ device_mqtt=self.device_mqtt,
136
+ skill_config=self.skill_config,
137
+ logger=self.logger,
138
+ )
139
+
140
+ # Start automatic image rotation scheduler
141
+ self.add_task(self._start_rotation_scheduler(), name="image_rotation")
142
+
143
+ # Subscribe to device topics
144
+ await self.device_mqtt.subscribe_device_topics()
145
+
146
+ # Start listening for device messages
147
+ await self._listen_device_mqtt()
148
+
149
+ async def _listen_device_mqtt(self) -> None:
150
+ """Listen for incoming device MQTT messages."""
151
+ if self.device_mqtt is None:
152
+ raise RuntimeError("Device MQTT client not initialized")
153
+
154
+ async for message in self.device_mqtt.messages():
155
+ try:
156
+ await self._handle_device_message(message)
157
+ except Exception as e:
158
+ self.logger.error("Error handling device message: %s", e, exc_info=True)
159
+
160
+ async def _handle_device_message(self, message: aiomqtt.Message) -> None:
161
+ """Route device MQTT message to appropriate handler.
162
+
163
+ Args:
164
+ message: Incoming MQTT message
165
+ """
166
+ if self.device_mqtt is None:
167
+ return
168
+
169
+ topic = str(message.topic)
170
+ payload = self.device_mqtt.decode_payload(message.payload)
171
+
172
+ if payload is None:
173
+ self.logger.warning("Failed to decode device message payload")
174
+ return
175
+
176
+ if topic == self.device_mqtt.REGISTER_TOPIC:
177
+ await self._handle_registration(payload)
178
+ elif "/status" in topic:
179
+ device_id = self.device_mqtt.extract_device_id_from_topic(topic)
180
+ if device_id:
181
+ await self._handle_acknowledge(device_id, payload)
182
+
183
+ async def _handle_registration(self, payload: dict) -> None:
184
+ """Handle device registration request.
185
+
186
+ Registers device in GlobalDevice registry and creates DeviceDisplayState.
187
+
188
+ Args:
189
+ payload: Registration payload from device
190
+ """
191
+ if self.device_mqtt is None:
192
+ self.logger.error("Device MQTT client not initialized")
193
+ return
194
+
195
+ try:
196
+ registration = DeviceRegistration.model_validate(payload)
197
+ except ValidationError as e:
198
+ self.logger.error("Invalid registration payload: %s", e)
199
+ return
200
+
201
+ # Build device_attributes with display hardware info
202
+ device_attributes = {
203
+ "display_width": registration.display.width,
204
+ "display_height": registration.display.height,
205
+ "orientation": registration.display.orientation,
206
+ "model": registration.display.model,
207
+ }
208
+
209
+ # Build pattern list for voice command matching
210
+ patterns = [
211
+ registration.device_id,
212
+ f"{registration.device_id} display",
213
+ f"{registration.device_id} frame",
214
+ ]
215
+ if registration.room:
216
+ patterns.extend(
217
+ [
218
+ f"{registration.room} display",
219
+ f"{registration.room} picture frame",
220
+ f"display in {registration.room}",
221
+ ]
222
+ )
223
+
224
+ # Check if device already exists
225
+ existing_device = self._find_device_by_name(registration.device_id)
226
+
227
+ if existing_device:
228
+ # Update existing device
229
+ global_device_id = await self._update_device(existing_device.id, device_attributes, patterns)
230
+ status: str = "updated"
231
+ self.logger.info("Updated device registration: %s", registration.device_id)
232
+ else:
233
+ # Register new device in global registry
234
+ global_device_id = await self.register_device(
235
+ device_type="picture_display",
236
+ name=registration.device_id,
237
+ pattern=patterns,
238
+ room=registration.room,
239
+ device_attributes=device_attributes,
240
+ )
241
+ status = "registered"
242
+ self.logger.info("New device registered: %s", registration.device_id)
243
+
244
+ # Create/update DeviceDisplayState
245
+ await self._ensure_display_state(global_device_id)
246
+
247
+ # Send registration response with MinIO credentials
248
+ response = RegistrationResponse(
249
+ status=status, # type: ignore[arg-type]
250
+ minio_endpoint=self.minio_config.endpoint,
251
+ minio_bucket=self.minio_config.bucket,
252
+ minio_access_key=self.minio_config.reader_access_key,
253
+ minio_secret_key=self.minio_config.reader_secret_key,
254
+ minio_secure=self.minio_config.secure,
255
+ )
256
+ await self.device_mqtt.publish_registered(registration.device_id, response)
257
+
258
+ def _find_device_by_name(self, name: str) -> GlobalDevice | None:
259
+ """Find a device in the global_devices cache by name.
260
+
261
+ Args:
262
+ name: Device name to search for
263
+
264
+ Returns:
265
+ GlobalDevice if found, None otherwise
266
+ """
267
+ device: GlobalDevice
268
+ for device in self.global_devices:
269
+ if device.name == name:
270
+ return device
271
+ return None
272
+
273
+ async def _update_device(self, device_id: UUID, device_attributes: dict, patterns: list[str]) -> UUID:
274
+ """Update an existing device's attributes and patterns.
275
+
276
+ Args:
277
+ device_id: GlobalDevice UUID
278
+ device_attributes: New device attributes
279
+ patterns: New pattern list
280
+
281
+ Returns:
282
+ The device UUID
283
+ """
284
+ async with AsyncSession(self.engine) as session:
285
+ result = await session.exec(select(GlobalDevice).where(GlobalDevice.id == device_id))
286
+ device = result.first()
287
+ if device:
288
+ device.device_attributes = device_attributes
289
+ device.pattern = patterns
290
+ await session.commit()
291
+
292
+ # Refresh local device cache
293
+ self.global_devices = await self.get_skill_devices()
294
+
295
+ return device_id
296
+
297
+ async def _ensure_display_state(self, global_device_id: UUID) -> None:
298
+ """Ensure DeviceDisplayState exists for a device.
299
+
300
+ Creates the state record if it doesn't exist, sets is_online=True.
301
+
302
+ Args:
303
+ global_device_id: GlobalDevice UUID
304
+ """
305
+ async with AsyncSession(self.engine) as session:
306
+ result = await session.exec(
307
+ select(DeviceDisplayState).where(DeviceDisplayState.global_device_id == global_device_id)
308
+ )
309
+ display_state = result.first()
310
+
311
+ if display_state:
312
+ display_state.is_online = True
313
+ else:
314
+ display_state = DeviceDisplayState(
315
+ global_device_id=global_device_id,
316
+ is_online=True,
317
+ )
318
+ session.add(display_state)
319
+
320
+ await session.commit()
321
+
322
+ async def _handle_acknowledge(self, device_name: str, payload: dict) -> None:
323
+ """Handle device acknowledgment after display command.
324
+
325
+ Args:
326
+ device_name: Device name from topic
327
+ payload: Acknowledgment payload from device
328
+ """
329
+ try:
330
+ ack = DeviceAcknowledge.model_validate(payload)
331
+ except ValidationError as e:
332
+ self.logger.error("Invalid acknowledge payload from %s: %s", device_name, e)
333
+ return
334
+
335
+ # Find device by name
336
+ device = self._find_device_by_name(device_name)
337
+ if device is None:
338
+ self.logger.warning("Acknowledge from unknown device: %s", device_name)
339
+ return
340
+
341
+ if not ack.successful_display_change:
342
+ self.logger.warning(
343
+ "Device %s failed to display image: %s",
344
+ device_name,
345
+ ack.error or "unknown error",
346
+ )
347
+
348
+ self.logger.debug("Processed acknowledge for device: %s", device_name)
349
+
350
+ async def process_request(self, intent_request: IntentRequest) -> None:
351
+ """Process voice command intent.
352
+
353
+ Args:
354
+ intent_request: Validated intent request
355
+ """
356
+ intent_type = intent_request.classified_intent.intent_type
357
+
358
+ match intent_type:
359
+ case IntentType.MEDIA_NEXT:
360
+ await self._handle_media_next(intent_request)
361
+ case IntentType.QUERY_STATUS:
362
+ await self._handle_query_status(intent_request)
363
+ case IntentType.SYSTEM_HELP:
364
+ await self._handle_system_help(intent_request)
365
+ case _:
366
+ self.logger.warning("Unhandled intent type: %s", intent_type)
367
+
368
+ async def _select_device_for_request(self, intent_request: IntentRequest) -> GlobalDevice | None:
369
+ """Select appropriate device based on room or explicit naming.
370
+
371
+ Priority:
372
+ 1. Explicitly named device in entities
373
+ 2. Device in same room as request
374
+ 3. First online device (fallback)
375
+
376
+ Args:
377
+ intent_request: Intent request with client info and entities
378
+
379
+ Returns:
380
+ Selected GlobalDevice or None if no suitable device found
381
+ """
382
+ device: GlobalDevice # Type annotation for loop variable
383
+
384
+ # Check for explicit device name in entities
385
+ device_entities = intent_request.classified_intent.entities.get("device", [])
386
+ if device_entities:
387
+ device_name = device_entities[0].normalized_value
388
+ # Search in global_devices cache for pattern match
389
+ for device in self.global_devices:
390
+ if device_name.lower() in [p.lower() for p in device.pattern] and await self._is_device_online(
391
+ device.id
392
+ ):
393
+ self.logger.debug("Selected device by name: %s", device.name)
394
+ return device
395
+
396
+ # Room-based selection using global device registry
397
+ request_room = intent_request.client_request.room
398
+ if request_room:
399
+ for device in self.global_devices:
400
+ if device.room and device.room.name == request_room and await self._is_device_online(device.id):
401
+ self.logger.debug("Selected device by room: %s (room: %s)", device.name, request_room)
402
+ return device
403
+
404
+ # Fallback: first online device
405
+ for device in self.global_devices:
406
+ if await self._is_device_online(device.id):
407
+ self.logger.debug("Selected first online device: %s", device.name)
408
+ return device
409
+
410
+ return None
411
+
412
+ async def _is_device_online(self, global_device_id: UUID) -> bool:
413
+ """Check if a device is online.
414
+
415
+ Args:
416
+ global_device_id: GlobalDevice UUID
417
+
418
+ Returns:
419
+ True if device is online, False otherwise
420
+ """
421
+ async with AsyncSession(self.engine) as session:
422
+ result = await session.exec(
423
+ select(DeviceDisplayState).where(DeviceDisplayState.global_device_id == global_device_id)
424
+ )
425
+ display_state = result.first()
426
+ return display_state is not None and display_state.is_online
427
+
428
+ async def _handle_media_next(self, intent_request: IntentRequest) -> None:
429
+ """Handle 'next picture' command.
430
+
431
+ Args:
432
+ intent_request: Intent request with client info
433
+ """
434
+ if self.image_manager is None:
435
+ await self.send_response(
436
+ "Picture display service is not ready. Please try again later.",
437
+ intent_request.client_request,
438
+ )
439
+ return
440
+
441
+ # Select device using room-based or explicit naming
442
+ device = await self._select_device_for_request(intent_request)
443
+ if device is None:
444
+ await self.send_response(
445
+ "No picture displays are currently online.",
446
+ intent_request.client_request,
447
+ )
448
+ return
449
+
450
+ # Get next image
451
+ image = await self.image_manager.get_next_image_for_device(device)
452
+ if image is None:
453
+ await self.send_response(
454
+ "No images available to display.",
455
+ intent_request.client_request,
456
+ )
457
+ return
458
+
459
+ # Send display command
460
+ await self.image_manager.send_display_command(device, image)
461
+
462
+ # Send voice response
463
+ template = self.template_env.get_template("next_picture.j2")
464
+ response_text = template.render(image=image)
465
+ await self.send_response(response_text, intent_request.client_request)
466
+
467
+ async def _handle_query_status(self, intent_request: IntentRequest) -> None:
468
+ """Handle 'what am I seeing?' command.
469
+
470
+ Args:
471
+ intent_request: Intent request with client info
472
+ """
473
+ if self.image_manager is None:
474
+ await self.send_response(
475
+ "Picture display service is not ready.",
476
+ intent_request.client_request,
477
+ )
478
+ return
479
+
480
+ # Select device using room-based or explicit naming
481
+ device = await self._select_device_for_request(intent_request)
482
+ if device is None:
483
+ await self.send_response(
484
+ "No picture displays are currently online.",
485
+ intent_request.client_request,
486
+ )
487
+ return
488
+
489
+ # Get current image
490
+ image = await self.image_manager.get_current_image_for_device(device.id)
491
+ if image is None:
492
+ await self.send_response(
493
+ "No image is currently being displayed.",
494
+ intent_request.client_request,
495
+ )
496
+ return
497
+
498
+ # Send voice response with image description
499
+ template = self.template_env.get_template("describe_image.j2")
500
+ response_text = template.render(image=image)
501
+ await self.send_response(response_text, intent_request.client_request)
502
+
503
+ async def _handle_system_help(self, intent_request: IntentRequest) -> None:
504
+ """Handle help request.
505
+
506
+ Args:
507
+ intent_request: Intent request with client info
508
+ """
509
+ template = self.template_env.get_template("help.j2")
510
+ response_text = template.render()
511
+ await self.send_response(response_text, intent_request.client_request)
512
+
513
+ async def _start_rotation_scheduler(self) -> None:
514
+ """Background task for automatic image rotation.
515
+
516
+ Periodically checks for devices where scheduled_next_at has passed
517
+ and rotates to the next image.
518
+ """
519
+ while True:
520
+ await asyncio.sleep(self.rotation_check_interval)
521
+
522
+ if self.image_manager is None:
523
+ continue
524
+
525
+ try:
526
+ await self._rotate_due_devices()
527
+ except Exception as e:
528
+ self.logger.error("Error in rotation scheduler: %s", e, exc_info=True)
529
+
530
+ async def _rotate_due_devices(self) -> None:
531
+ """Find and rotate images on devices past their scheduled time."""
532
+ now = datetime.now()
533
+
534
+ async with AsyncSession(self.engine) as session:
535
+ # Find online devices due for rotation
536
+ query = (
537
+ select(DeviceDisplayState)
538
+ .where(DeviceDisplayState.is_online == True) # noqa: E712
539
+ .where(DeviceDisplayState.scheduled_next_at <= now)
540
+ )
541
+ result = await session.exec(query)
542
+ due_states = result.all()
543
+
544
+ for state in due_states:
545
+ await self._rotate_single_device(state.global_device_id)
546
+
547
+ async def _rotate_single_device(self, global_device_id: UUID) -> None:
548
+ """Rotate image on a single device.
549
+
550
+ Args:
551
+ global_device_id: UUID of the GlobalDevice to rotate
552
+ """
553
+ if self.image_manager is None:
554
+ return
555
+
556
+ # Find the GlobalDevice from cache
557
+ device: GlobalDevice | None = None
558
+ for d in self.global_devices:
559
+ if d.id == global_device_id:
560
+ device = d
561
+ break
562
+
563
+ if device is None:
564
+ self.logger.warning("Device %s not found in cache for rotation", global_device_id)
565
+ return
566
+
567
+ # Get next image
568
+ image = await self.image_manager.get_next_image_for_device(device)
569
+ if image is None:
570
+ self.logger.debug("No images available for device %s", device.name)
571
+ return
572
+
573
+ # Send display command (this updates scheduled_next_at)
574
+ await self.image_manager.send_display_command(device, image)
575
+ self.logger.info("Auto-rotated to image '%s' on %s", image.title or image.id, device.name)
File without changes
@@ -0,0 +1,9 @@
1
+ """Services for the Picture Display Skill."""
2
+
3
+ from .device_mqtt_client import DeviceMqttClient
4
+ from .image_manager import ImageManager
5
+
6
+ __all__ = [
7
+ "DeviceMqttClient",
8
+ "ImageManager",
9
+ ]