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,152 @@
1
+ """CLI entrypoint for the Picture Display Skill."""
2
+
3
+ import asyncio
4
+ import pathlib
5
+ from typing import Annotated
6
+
7
+ import jinja2
8
+ import sqlalchemy
9
+ import typer
10
+ from private_assistant_commons import (
11
+ MqttConfig,
12
+ create_skill_engine,
13
+ mqtt_connection_handler,
14
+ skill_config,
15
+ skill_logger,
16
+ )
17
+ from sqlmodel import select
18
+ from sqlmodel.ext.asyncio.session import AsyncSession
19
+
20
+ from private_assistant_picture_display_skill.config import PictureSkillConfig
21
+ from private_assistant_picture_display_skill.immich import ImmichSyncService
22
+ from private_assistant_picture_display_skill.models.device import DeviceDisplayState
23
+ from private_assistant_picture_display_skill.models.image import Image
24
+ from private_assistant_picture_display_skill.models.immich_sync_job import ImmichSyncJob
25
+ from private_assistant_picture_display_skill.picture_skill import PictureSkill
26
+
27
+ app = typer.Typer(help="Picture Display Skill for Inky e-ink devices")
28
+
29
+
30
+ @app.command()
31
+ def main(config_path: Annotated[pathlib.Path, typer.Argument(envvar="PRIVATE_ASSISTANT_CONFIG_PATH")]) -> None:
32
+ """Run the Picture Display Skill.
33
+
34
+ Args:
35
+ config_path: Path to YAML configuration file or directory
36
+ """
37
+ asyncio.run(start_skill(config_path))
38
+
39
+
40
+ async def start_skill(config_path: pathlib.Path) -> None:
41
+ """Start the Picture Display Skill with all required services.
42
+
43
+ Args:
44
+ config_path: Path to YAML configuration file or directory
45
+ """
46
+ # Set up logger early on
47
+ logger = skill_logger.SkillLogger.get_logger("Private Assistant PictureSkill")
48
+
49
+ # Load configuration from YAML
50
+ config_obj = skill_config.load_config(config_path, PictureSkillConfig)
51
+
52
+ # Create async database engine with connection pooling and resilience
53
+ # AIDEV-NOTE: create_skill_engine uses PostgresConfig from env (POSTGRES_*) and adds pool_pre_ping, pool_recycle
54
+ db_engine_async = create_skill_engine()
55
+
56
+ # Create only skill-specific tables, not all SQLModel metadata
57
+ # AIDEV-NOTE: Global device registry tables are managed by BaseSkill and commons
58
+ async with db_engine_async.begin() as conn:
59
+ # __table__ is a SQLAlchemy runtime attribute that mypy doesn't recognize
60
+ for table in [Image.__table__, DeviceDisplayState.__table__]: # type: ignore[attr-defined]
61
+ await conn.run_sync(table.create, checkfirst=True)
62
+
63
+ logger.info("Database tables initialized for Picture Display Skill")
64
+
65
+ # Set up Jinja2 template environment
66
+ template_env = jinja2.Environment(
67
+ loader=jinja2.PackageLoader("private_assistant_picture_display_skill", "templates"),
68
+ autoescape=True,
69
+ )
70
+
71
+ # Start the skill using the async MQTT connection handler
72
+ # AIDEV-NOTE: mqtt_connection_handler manages MQTT lifecycle with auto-reconnect
73
+ mqtt_config = MqttConfig()
74
+ await mqtt_connection_handler.mqtt_connection_handler(
75
+ PictureSkill,
76
+ config_obj,
77
+ mqtt_config=mqtt_config,
78
+ retry_interval=5,
79
+ logger=logger,
80
+ template_env=template_env,
81
+ engine=db_engine_async,
82
+ )
83
+
84
+
85
+ @app.command()
86
+ def immich_sync(
87
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show jobs that would be synced")] = False,
88
+ ) -> None:
89
+ """Sync images from Immich to local storage.
90
+
91
+ Executes all active sync jobs from the database. Each job defines filters
92
+ and selection criteria for fetching images from Immich.
93
+
94
+ Configuration is via environment variables:
95
+ - IMMICH_BASE_URL: Immich server URL
96
+ - IMMICH_API_KEY: API key for authentication
97
+ - MINIO_WRITER_*: MinIO connection for image storage
98
+ - POSTGRES_*: Database connection (from commons)
99
+ """
100
+ results = asyncio.run(run_immich_sync(dry_run))
101
+ typer.Exit(code=results)
102
+
103
+
104
+ async def run_immich_sync(dry_run: bool) -> int:
105
+ """Run the Immich sync operation for all active jobs.
106
+
107
+ Args:
108
+ dry_run: If True, only show what would be synced
109
+
110
+ Returns:
111
+ Exit code (0 for success, 1 for failure)
112
+ """
113
+ logger = skill_logger.SkillLogger.get_logger("Immich Sync")
114
+
115
+ # Create database engine
116
+ db_engine = create_skill_engine()
117
+
118
+ # Ensure required tables exist
119
+ async with db_engine.begin() as conn:
120
+ for table in [Image.__table__, ImmichSyncJob.__table__]: # type: ignore[attr-defined]
121
+ await conn.run_sync(table.create, checkfirst=True)
122
+
123
+ if dry_run:
124
+ async with AsyncSession(db_engine) as session:
125
+ stmt = select(ImmichSyncJob).where(ImmichSyncJob.is_active == sqlalchemy.true())
126
+ db_result = await session.exec(stmt)
127
+ jobs = list(db_result.all())
128
+
129
+ if not jobs:
130
+ logger.warning("No active sync jobs found")
131
+ return 0
132
+
133
+ logger.info("Dry run mode - would process %d job(s):", len(jobs))
134
+ for job in jobs:
135
+ logger.info(" - %s: strategy=%s, count=%d", job.name, job.strategy, job.count)
136
+ return 0
137
+
138
+ # Run sync for all active jobs
139
+ sync_service = ImmichSyncService(
140
+ engine=db_engine,
141
+ logger=logger,
142
+ )
143
+
144
+ results = await sync_service.sync_all_active_jobs()
145
+
146
+ # Return non-zero exit code if any job had errors
147
+ has_errors = any(not r.success for r in results.values())
148
+ return 1 if has_errors else 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ app()
@@ -0,0 +1,24 @@
1
+ """Database models and command schemas for the Picture Display Skill."""
2
+
3
+ from .commands import (
4
+ DeviceAcknowledge,
5
+ DeviceRegistration,
6
+ DisplayCommand,
7
+ DisplayInfo,
8
+ RegistrationResponse,
9
+ )
10
+ from .device import DeviceDisplayState
11
+ from .image import Image
12
+ from .immich_sync_job import ImmichSyncJob, SyncStrategy
13
+
14
+ __all__ = [
15
+ "DeviceAcknowledge",
16
+ "DeviceDisplayState",
17
+ "DeviceRegistration",
18
+ "DisplayCommand",
19
+ "DisplayInfo",
20
+ "Image",
21
+ "ImmichSyncJob",
22
+ "RegistrationResponse",
23
+ "SyncStrategy",
24
+ ]
@@ -0,0 +1,63 @@
1
+ """MQTT command and response models for device communication."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class DisplayInfo(BaseModel):
9
+ """Display characteristics sent during device registration."""
10
+
11
+ width: int = Field(description="Display width in pixels")
12
+ height: int = Field(description="Display height in pixels")
13
+ orientation: Literal["landscape", "portrait"] = Field(description="Display orientation")
14
+ model: str | None = Field(default=None, description="Display model identifier")
15
+
16
+
17
+ class DeviceRegistration(BaseModel):
18
+ """Device registration payload from Pi agent.
19
+
20
+ Sent to topic: inky/register
21
+ """
22
+
23
+ device_id: str = Field(description="Unique device identifier")
24
+ display: DisplayInfo = Field(description="Display characteristics")
25
+ room: str | None = Field(default=None, description="Room where device is located")
26
+
27
+
28
+ class RegistrationResponse(BaseModel):
29
+ """Response sent after successful device registration.
30
+
31
+ Sent to topic: inky/{device_id}/registered
32
+ """
33
+
34
+ status: Literal["registered", "updated"] = Field(description="Registration result")
35
+ minio_endpoint: str = Field(description="MinIO server endpoint")
36
+ minio_bucket: str = Field(description="Bucket containing images")
37
+ minio_access_key: str = Field(description="Read-only access key")
38
+ minio_secret_key: str = Field(description="Read-only secret key")
39
+ minio_secure: bool = Field(default=False, description="Use HTTPS for MinIO")
40
+
41
+
42
+ class DisplayCommand(BaseModel):
43
+ """Command sent to device to display an image.
44
+
45
+ Sent to topic: inky/{device_id}/command
46
+ """
47
+
48
+ action: Literal["display", "clear", "status"] = Field(description="Command action")
49
+ image_path: str | None = Field(default=None, description="MinIO object path for display action")
50
+ image_id: str | None = Field(default=None, description="Image UUID for tracking")
51
+ title: str | None = Field(default=None, description="Image title for device logging")
52
+
53
+
54
+ class DeviceAcknowledge(BaseModel):
55
+ """Acknowledge after DisplayCommand from device.
56
+
57
+ Sent to topic: inky/{device_id}/status
58
+ """
59
+
60
+ device_id: str = Field(description="Device identifier")
61
+ image_id: str | None = Field(default=None, description="Currently displayed image UUID")
62
+ successful_display_change: bool = Field(description="Was display change successful?")
63
+ error: str | None = Field(default=None, description="Error message if any")
@@ -0,0 +1,30 @@
1
+ """Device display state model for Inky displays."""
2
+
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ from sqlmodel import Field, SQLModel
7
+
8
+
9
+ class DeviceDisplayState(SQLModel, table=True):
10
+ """Current display state for a device.
11
+
12
+ Tracks what image is currently displayed, online status, and scheduling
13
+ information for automatic image rotation. Links to GlobalDevice from
14
+ commons via global_device_id.
15
+
16
+ Attributes:
17
+ global_device_id: Foreign key to global_devices.id
18
+ is_online: Whether device is currently reachable
19
+ current_image_id: Currently displayed image (nullable)
20
+ displayed_since: When current image was displayed
21
+ scheduled_next_at: When to show next image
22
+ """
23
+
24
+ __tablename__ = "device_display_states"
25
+
26
+ global_device_id: UUID = Field(primary_key=True, foreign_key="global_devices.id")
27
+ is_online: bool = Field(default=True, description="Whether device is currently reachable")
28
+ current_image_id: UUID | None = Field(default=None, foreign_key="images.id")
29
+ displayed_since: datetime | None = Field(default=None, description="When current image was displayed")
30
+ scheduled_next_at: datetime = Field(default_factory=datetime.now, description="Scheduled time for next image")
@@ -0,0 +1,62 @@
1
+ """Image model for storing picture metadata."""
2
+
3
+ from datetime import datetime
4
+ from uuid import UUID, uuid4
5
+
6
+ from sqlmodel import Field, SQLModel
7
+
8
+
9
+ class Image(SQLModel, table=True):
10
+ """Image metadata stored in the database.
11
+
12
+ Images are fetched from various sources (manual upload, Immich, Unsplash, etc.)
13
+ and stored in MinIO. This table tracks metadata for display scheduling and
14
+ voice command responses.
15
+
16
+ Attributes:
17
+ id: Unique identifier for the image
18
+ source_name: Source identifier (e.g., "manual", "immich", "unsplash")
19
+ storage_path: Path to image in MinIO bucket
20
+ title: Optional title for voice responses
21
+ description: Optional description for "what am I seeing?" queries
22
+ author: Optional author/photographer name
23
+ source_url: Optional URL to original source
24
+ display_duration_seconds: How long to show this image (default 3600)
25
+ priority: Weight for future priority-based selection (default 0)
26
+ original_width: Image width in pixels
27
+ original_height: Image height in pixels
28
+ last_displayed_at: When the image was last shown (for FIFO selection)
29
+ expires_at: Optional expiration time for auto-cleanup
30
+ created_at: When record was created (replaces fetched_at)
31
+ updated_at: When record was last updated
32
+ tags: Comma-separated tags for categorization
33
+ """
34
+
35
+ __tablename__ = "images"
36
+
37
+ id: UUID = Field(default_factory=uuid4, primary_key=True)
38
+ source_name: str = Field(index=True, description="Source identifier (e.g. manual, unsplash)")
39
+ storage_path: str = Field(description="MinIO object path")
40
+
41
+ # Metadata for voice responses
42
+ title: str | None = Field(default=None, description="Image title for voice responses")
43
+ description: str | None = Field(default=None, description="Description for 'what am I seeing?'")
44
+ author: str | None = Field(default=None, description="Author/photographer name")
45
+ source_url: str | None = Field(default=None, description="Original source URL")
46
+
47
+ # Display settings
48
+ display_duration_seconds: int = Field(default=600, description="Display duration in seconds")
49
+ priority: int = Field(default=0, description="Priority weight for selection (higher = more likely)")
50
+
51
+ # Image dimensions for device compatibility
52
+ original_width: int | None = Field(default=None, description="Image width in pixels")
53
+ original_height: int | None = Field(default=None, description="Image height in pixels")
54
+
55
+ # Timestamps
56
+ last_displayed_at: datetime | None = Field(default=None, description="Last display time for FIFO")
57
+ expires_at: datetime | None = Field(default=None, description="Expiration time for cleanup")
58
+ created_at: datetime = Field(default_factory=datetime.now, description="When record was created")
59
+ updated_at: datetime = Field(default_factory=datetime.now, description="When record was last updated")
60
+
61
+ # Categorization
62
+ tags: str | None = Field(default=None, description="Comma-separated tags for categorization")
@@ -0,0 +1,109 @@
1
+ """Immich sync job configuration model."""
2
+
3
+ import enum
4
+ from datetime import datetime
5
+ from uuid import UUID, uuid4
6
+
7
+ from sqlalchemy import Column
8
+ from sqlalchemy.types import JSON
9
+ from sqlmodel import Field, SQLModel
10
+
11
+
12
+ class SyncStrategy(str, enum.Enum):
13
+ """Strategy for selecting images from Immich.
14
+
15
+ Values are uppercase to match PostgreSQL enum labels created by SQLAlchemy.
16
+ """
17
+
18
+ RANDOM = "RANDOM"
19
+ SMART = "SMART" # CLIP semantic search
20
+
21
+
22
+ class ImmichSyncJob(SQLModel, table=True):
23
+ """Immich sync job configuration stored in database.
24
+
25
+ Each job defines a set of filters and selection criteria for syncing
26
+ images from Immich. Jobs can be activated/deactivated and target
27
+ specific devices to determine display requirements.
28
+
29
+ Attributes:
30
+ id: Unique job identifier
31
+ name: Human-readable job name (unique)
32
+ is_active: Whether job should be executed during sync
33
+ target_device_id: Device this job syncs for (determines dimensions/orientation)
34
+ strategy: Selection strategy - 'random' or 'smart' (CLIP search)
35
+ query: Semantic search query (required for 'smart' strategy)
36
+ count: Number of images to sync per run
37
+ random_pick: Randomly sample from smart search results
38
+ overfetch_multiplier: Fetch more images to allow for client-side filtering
39
+ album_ids: Filter by album UUIDs
40
+ person_ids: Filter by recognized person UUIDs
41
+ tag_ids: Filter by tag UUIDs
42
+ is_favorite: Filter favorites only
43
+ city: Filter by city name
44
+ state: Filter by state/region
45
+ country: Filter by country
46
+ taken_after: Photos taken after this date
47
+ taken_before: Photos taken before this date
48
+ make: Camera make filter
49
+ camera_model: Camera model filter
50
+ rating: Minimum rating filter (0-5)
51
+ min_color_score: Minimum color compatibility for Spectra 6 (0.0-1.0)
52
+ """
53
+
54
+ __tablename__ = "immich_sync_jobs"
55
+
56
+ id: UUID = Field(default_factory=uuid4, primary_key=True)
57
+ name: str = Field(unique=True, index=True, description="Unique job name")
58
+ is_active: bool = Field(default=True, description="Whether job is active")
59
+
60
+ # Target device - determines display requirements (width, height, orientation)
61
+ target_device_id: UUID = Field(
62
+ foreign_key="global_devices.id",
63
+ description="Device this job syncs for. Display requirements from device_attributes.",
64
+ )
65
+
66
+ # Selection strategy
67
+ strategy: SyncStrategy = Field(
68
+ default=SyncStrategy.RANDOM,
69
+ description="Selection strategy: 'random' or 'smart' (CLIP semantic search)",
70
+ )
71
+ query: str | None = Field(
72
+ default=None,
73
+ description="Semantic search query for 'smart' strategy",
74
+ )
75
+ count: int = Field(default=10, ge=1, le=1000, description="Images to sync per run")
76
+ random_pick: bool = Field(
77
+ default=False,
78
+ description="Randomly sample from smart search results",
79
+ )
80
+ overfetch_multiplier: int = Field(
81
+ default=3,
82
+ ge=1,
83
+ le=10,
84
+ description="Multiplier for overfetching when client-side filters active",
85
+ )
86
+
87
+ # API filters (stored as JSON for list types)
88
+ album_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
89
+ person_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
90
+ tag_ids: list[str] | None = Field(default=None, sa_column=Column(JSON))
91
+ is_favorite: bool | None = Field(default=None, description="Filter favorites only")
92
+ city: str | None = Field(default=None, description="Filter by city")
93
+ state: str | None = Field(default=None, description="Filter by state/region")
94
+ country: str | None = Field(default=None, description="Filter by country")
95
+ taken_after: datetime | None = Field(default=None, description="Photos taken after")
96
+ taken_before: datetime | None = Field(default=None, description="Photos taken before")
97
+ rating: int | None = Field(default=None, ge=0, le=5, description="Minimum rating")
98
+
99
+ # Client-side filter
100
+ min_color_score: float = Field(
101
+ default=0.5,
102
+ ge=0.0,
103
+ le=1.0,
104
+ description="Minimum color compatibility score for Spectra 6 palette",
105
+ )
106
+
107
+ # Timestamps
108
+ created_at: datetime = Field(default_factory=datetime.now, description="When created")
109
+ updated_at: datetime = Field(default_factory=datetime.now, description="When last updated")