FRAMEO Photo Frame via IMMICH sync

Ich habe gesehen, dass ein FRAMEO Bilderrahmen mit IMMICH gekoppelt werden kann. Allerdings muss man dazu die „Launcher-App“ im Gerät austauschen. Damit besteht die Gefahr dass das Gerät nicht mehr reagiert und man verliert die User-Interaktionen die der original FRAMEO Launcher bietet. Allerdings gewinnt man den Vorteil, dass man seine Fotos auf IMMICH verwaltet und keine Fotos über einen Cloud-Service geschickt werden müssen. Das ist ein Vorteil, wenn man sowieso schon alle Fotos in IMMICH hat.


Ich habe nach einer Möglichkeit gesucht, die Vorteile beider Optionen zu erhalten:

  • FRAMEO Gerät bleibt nahezu im Originalzustand
    • Garantie bleibt erhalten
    • Bedienung am Gerät bleibt erhalten
    • Bilder können bei Bedarf nach wie vor per App an den Rahmen gesendet werden
  • Bilder werden von einem bestimmten Album in IMMICH automatisch mit dem Rahmen synchronisiert

Exploration der Möglichkeiten

Mein erster Gedanke war es die Möglichkeit zu nutzen, Bilder per USB auf den Rahmen zu übertragen. Hier kann mit der PTP-Verbindung auf den Speicher zugegriffen werden und Bilder erscheinen automatisch in der Dia-Show.

Man könnte also z.B. mit einem Raspberry Pi der sich hinter dem Rahmen versteckt Bilder von einer Netzwerkquelle laden und per PTP auf den Rahmen laden.

Nachteile:

  • Extra Gerät wird benötigt
  • PTP unter Linux umständlich zu handhaben

Die nächste Idee kam mir beim Rumspielen mit den ADB (Android Debug Bridge) Befehlen. Man kann Dateien auch per ADB Push auf das Gerät senden. Wenn man sie in dem Verzeichnis ablegt, welches auch von PTP verwendet wird, erscheinen sie ebenfalls automatisch in der Dia-Show.

adb push image.jpg /sdcard/DCIM/

Noch besser: ADB kann auch über TCP/IP konfiguriert werden, sodass der Sender nicht mehr per USB mit dem Rahmen verbunden sein muss. Perfekte Voraussetzungen um das ganze in einem Docker-Container abzubilden. 🙂

adb tcpip [PORT]                               #für direkte Aktivierung
adb shell setprop persist.adb.tcp.port 5555    #für persistente Einstellung

Dann das USB-Kabel entfernen und per Netzwerk mit adb verbinden:

adb connect IPADDR:PORT

Da ich alle Fotos in IMMIICH verwalte, möchte ich nach Möglichkeit nicht erst die Fotos runterladen und woanders hochladen. Es soll also ein Album in IMMICH geben, zu dem ich die Fotos hinzufüge, die mit dem Bilderrahmen synchronisiert werden sollen.

IMMICH bietet die Möglichkeit per API-Key auf die Assets eines Albums zuzugreifen.

Zielarchitektur

IMMICH Server

Docker-Container

IMMICH API – nur neue Bilder herunterladen

ADB Push (TCP/IP)

FRAMEO Device

Der Upload sollte nach Möglichkeit nur neue Bilder berücksichtigen. Hierzu bietet es sich an, den Status in einer kleinen Datenbank z.B. SQlite zu halten.

Der Docker-Container, nennen wir ihn gerne immich-adb-sync, setzt sich also aus ein paar Dateien zusammen:

  • Dockerfile
  • docker-compose.yml
  • sync.py
  • entrypoint.sh

IMMICH API-key

Um einen API-Key in IMMICH zu definieren, werden mindestens diese Häckchen benötigt:

Dockerfile

FROM python:3.12-slim

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    android-sdk-platform-tools \
    curl \
    bash \
    sqlite3 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY sync.py .
COPY entrypoint.sh .

RUN chmod +x /app/entrypoint.sh

CMD ["/app/entrypoint.sh"]

docker-compose.yml

Die blauen Werte müssen unbedingt angepasst werden.

services:
  immich-adb-sync:
    build: .
    container_name: immich-adb-sync
    restart: unless-stopped
    environment:
      IMMICH_URL: "https://immich.example.com/api"
      IMMICH_API_KEY: "REPLACE_API_KEY"
      IMMICH_ALBUM: "Album-Name"
      ADB_TARGET: "ANDROID-IP:PORT"
      ANDROID_TARGET_DIR: "/sdcard/DCIM/"
      SYNC_INTERVAL: "300"
      TZ: "Europe/Berlin"
    volumes:
      - ./cache:/cache
      - ./data:/data
      - ./logs:/logs

sync.py

import os
import time
import sqlite3
import requests
import subprocess
from pathlib import Path

IMMICH_URL = os.environ["IMMICH_URL"]
IMMICH_API_KEY = os.environ["IMMICH_API_KEY"]
IMMICH_ALBUM = os.environ["IMMICH_ALBUM"]

ADB_TARGET = os.environ["ADB_TARGET"]
ANDROID_TARGET_DIR = os.environ["ANDROID_TARGET_DIR"]

SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "300"))
CACHE_DIR = "/cache"
DB_PATH = "/data/state.db"
HEADERS = {
    "x-api-key": IMMICH_API_KEY,
    "Accept": "application/json"
}
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS synced_assets (
    asset_id TEXT PRIMARY KEY,
    filename TEXT,
    uploaded_at TEXT
)
""")

conn.commit()

def log(msg):
    print(msg, flush=True)

def adb_connect():
    log(f"ADB connect -> {ADB_TARGET}")
    subprocess.run(
        ["adb", "connect", ADB_TARGET],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )


def adb_push(local_file, remote_file):
    log(f"Push: {local_file}")
    subprocess.run(
        ["adb", "push", local_file, remote_file],
        check=True
    )

def adb_delete(remote_file):

    log(f"Entferne auf Android: {remote_file}")

    subprocess.run([
        "adb",
        "shell",
        "rm",
        "-f",
        remote_file
    ])

def media_scan(remote_file):
    subprocess.run([
        "adb",
        "shell",
        "am",
        "broadcast",
        "-a",
        "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
        "-d",
        f"file://{remote_file}"
    ])

def media_delete_scan(remote_file):
    subprocess.run([
        "adb",
        "shell",
        "am",
        "broadcast",
        "-a",
        "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
        "-d",
        f"file://{remote_file}"
    ])

def get_album_id():
    url = f"{IMMICH_URL}/albums"
    r = requests.get(url, headers=HEADERS)
    r.raise_for_status()
    albums = r.json()
    for album in albums:
        if album["albumName"] == IMMICH_ALBUM:
            return album["id"]
    raise Exception("Album nicht gefunden")

def get_album_assets(album_id):
    url = f"{IMMICH_URL}/albums/{album_id}"
    r = requests.get(url, headers=HEADERS)
    r.raise_for_status()
    data = r.json()
    return data["assets"]

def already_synced(asset_id):
    cur.execute(
        "SELECT asset_id FROM synced_assets WHERE asset_id=?",
        (asset_id,)
    )
    return cur.fetchone() is not None

def mark_synced(asset_id, filename):
    cur.execute(
        "INSERT OR REPLACE INTO synced_assets VALUES (?, ?, datetime('now'))",
        (asset_id, filename)
    )
    conn.commit()

def remove_synced(asset_id):
    cur.execute(
        "DELETE FROM synced_assets WHERE asset_id=?",
        (asset_id,)
    )
    conn.commit()

def get_all_synced_assets():
    cur.execute(
        "SELECT asset_id, filename FROM synced_assets"
    )
    return cur.fetchall()

def download_asset(asset):
    asset_id = asset["id"]
    filename = asset["originalFileName"]
    local_path = os.path.join(CACHE_DIR, filename)
    if Path(local_path).exists():
        return local_path
    log(f"Download: {filename}")
    url = f"{IMMICH_URL}/assets/{asset_id}/original"
    with requests.get(url, headers=HEADERS, stream=True) as r:
        r.raise_for_status()
        with open(local_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=8192):
                f.write(chunk)
    return local_path

def cleanup_removed_assets(current_assets):
    current_asset_ids = set()
    for asset in current_assets:
        current_asset_ids.add(asset["id"])
    synced_assets = get_all_synced_assets()
    for asset_id, filename in synced_assets:
        if asset_id not in current_asset_ids:
            log(f"Asset entfernt aus Immich-Album: {filename}")
            remote_file = f"{ANDROID_TARGET_DIR}{filename}"
            try:
                adb_delete(remote_file)
                media_delete_scan(remote_file)
            except Exception as e:
                log(f"Fehler beim Löschen auf Android: {e}")
            cache_file = os.path.join(CACHE_DIR, filename)
            if os.path.exists(cache_file):
                try:
                    os.remove(cache_file)
                    log(f"Cache entfernt: {filename}")
                except Exception as e:
                    log(f"Cache konnte nicht gelöscht werden: {e}")
            remove_synced(asset_id)
            log(f"State entfernt: {filename}")

def sync_once():
    adb_connect()
    album_id = get_album_id()
    assets = get_album_assets(album_id)
    log(f"Assets gefunden: {len(assets)}")
    cleanup_removed_assets(assets)
    for asset in assets:
        asset_id = asset["id"]
        filename = asset["originalFileName"]
        if already_synced(asset_id):
            log(f"Bereits synchronisiert: {filename}")
            continue
        try:
            local_file = download_asset(asset)
            remote_file = f"{ANDROID_TARGET_DIR}{filename}"
            adb_push(local_file, remote_file)
            media_scan(remote_file)
            mark_synced(asset_id, filename)
            log(f"Synchronisiert: {filename}")
        except Exception as e:
            log(f"Fehler bei {filename}: {e}")

while True:
    try:
        sync_once()
    except Exception as e:
        log(f"Globaler Fehler: {e}")
    log(f"Warte {SYNC_INTERVAL} Sekunden")
    time.sleep(SYNC_INTERVAL)

Wenn man nun alle Dateien in einen Order auf seinem Docker-Host ablegt kann mit

docker compose up -d --build

das Image gebaut, der Container gestartet werden. Als nächstes muss die adb-Verbindung aus dem Container hergestellt werden. Knackpunkt ist die ADB-Authorization.

ADB Authorization

Ich hatte Anfang ein Problem, dass die Authorization beim Verbinden des Containers per ADB zum FRAMEO nicht angefordert wurde. Geholfen hat dann über die Android-Entwicklereinstelllungen alle bisherigen Authentifizierungen zurück zu weisen und den ADP-Task im Container neu zu startet und verbunden.

adb kill-server
adb connect 192.xx.xx.xx:1234

Android Settings

In die Android Setting gelangt man mit dem adb-Befehl:

adb shell am start -a android.settings.SETTINGS

Kommentar hinterlassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Zeitlimit überschritten. Bitte vervollständigen Sie das Captcha noch einmal.