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.
- private_assistant_picture_display_skill/__init__.py +15 -0
- private_assistant_picture_display_skill/config.py +61 -0
- private_assistant_picture_display_skill/immich/__init__.py +20 -0
- private_assistant_picture_display_skill/immich/client.py +250 -0
- private_assistant_picture_display_skill/immich/config.py +94 -0
- private_assistant_picture_display_skill/immich/models.py +109 -0
- private_assistant_picture_display_skill/immich/payloads.py +55 -0
- private_assistant_picture_display_skill/immich/storage.py +127 -0
- private_assistant_picture_display_skill/immich/sync_service.py +501 -0
- private_assistant_picture_display_skill/main.py +152 -0
- private_assistant_picture_display_skill/models/__init__.py +24 -0
- private_assistant_picture_display_skill/models/commands.py +63 -0
- private_assistant_picture_display_skill/models/device.py +30 -0
- private_assistant_picture_display_skill/models/image.py +62 -0
- private_assistant_picture_display_skill/models/immich_sync_job.py +109 -0
- private_assistant_picture_display_skill/picture_skill.py +575 -0
- private_assistant_picture_display_skill/py.typed +0 -0
- private_assistant_picture_display_skill/services/__init__.py +9 -0
- private_assistant_picture_display_skill/services/device_mqtt_client.py +163 -0
- private_assistant_picture_display_skill/services/image_manager.py +175 -0
- private_assistant_picture_display_skill/templates/describe_image.j2 +11 -0
- private_assistant_picture_display_skill/templates/help.j2 +1 -0
- private_assistant_picture_display_skill/templates/next_picture.j2 +9 -0
- private_assistant_picture_display_skill/utils/__init__.py +15 -0
- private_assistant_picture_display_skill/utils/color_analysis.py +78 -0
- private_assistant_picture_display_skill/utils/image_processing.py +104 -0
- private_assistant_picture_display_skill/utils/metadata_builder.py +135 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/METADATA +47 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/RECORD +32 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/WHEEL +4 -0
- private_assistant_picture_display_skill-0.4.1.dist-info/entry_points.txt +3 -0
- 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
|