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