Python tools to grab Ambilight colors from a Philips TV (JointSpace API v6) and stream them live to WLED via UDP. Includes an interactive main.py that handles pairing once, stores config locally, and then just runs the sync.
main.py– interactive launcher (recommended entrypoint).tv2wled.py– core library/CLI (pair, discover, run).pair_request.py/grant_pair.py– optional manual two-step pairing helpers.requirements.txt– dependencies.config.json/pair_state.json– local runtime config; ignored by git.
- Install deps:
pip install -r requirements.txt - Run the interactive launcher:
python main.py
- Enter your TV IP when prompted; a PIN appears on the TV.
- Enter the TV PIN when asked.
- Enter your WLED IP/hostname (and optionally per-side LED counts).
config.jsonis written locally; it contains pairing creds and layout/WLED settings (git-ignored).
- On the next run, just call
python main.pyagain. It will reuseconfig.jsonand start syncing. Stop withCtrl+C.
- Discover TVs (best-effort SSDP):
python tv2wled.py discover
- Pair in one step and write config:
python tv2wled.py pair --tv 192.168.1.50 --out config.json
- Run sync (values fall back to
config.jsonwhen flags omitted):Omitpython tv2wled.py run \ --config config.json \ --wled 192.168.1.80 \ --fps 20 \ --tv 192.168.1.50 \ --top-leds 40 --right-leds 20 --bottom-leds 40 --left-leds 20 \ --clockwise 1 --start-corner bottom_left --offset 0
--top/right/bottom/left-ledsto auto-distribute using the TV topology andLED_COUNTenv (default 120). Use--flip-*/--offset/--start-cornerto match your strip orientation. Optional watchdog:--exit-after-no-data 30(or setexit_after_no_data_sinconfig.json) to stop the sync when Ambilight data is unavailable.
If your strip runs in one line and you want explicit index ranges per side (e.g., no bottom LEDs):
python tv2wled.py run --config config.json --wled 192.168.1.80 \
--segment-left 20:50 --segment-top 50:80 --segment-right 80:100 \
--bottom-leds 0- Ranges are
start:end(end exclusive). Each side is resampled to fit the range. --strip-lengthcan be provided if you want a fixed output length; otherwise the maxendis used.- Setting
bottom_ledsto0disables bottom-side sampling. - Advanced: partial side mapping with percentages:
start:end[:pctStart[:pctEnd]]. Example:--segment-left 10:20:0:20maps only the lower 20% of the left side into strip indices 10–19. You can repeat the same--segment-<side>flag to split a side across multiple ranges.
If you want to switch between two configs ("normal" / "max") from Home Assistant, you can run the included FastAPI wrapper in Docker.
It starts/stops tv2wled.py run as a subprocess and exposes simple endpoints.
- Create two config files next to this repo:
config_normal.jsonconfig_max.json
- Start the container:
docker compose up -d --build
- Switch modes via HTTP:
POST http://<host>:8000/mode/normalPOST http://<host>:8000/mode/maxPOST http://<host>:8000/stopGET http://<host>:8000/statusGET http://<host>:8000/logs?lines=200
Environment variables (optional):
TV2WLED_CONFIG_NORMAL(default/configs/normal.json)TV2WLED_CONFIG_MAX(default/configs/max.json)TV2WLED_DEFAULT_MODE(normal|max, defaultnormal)TV2WLED_AUTOSTART(1|0, default1)TV2WLED_EXTRA_ARGS(extra args appended totv2wled.py run ...)TV2WLED_EXIT_AFTER_NO_DATA_S(adds--exit-after-no-data Nautomatically)
rest_command:
tv2wled_normal:
url: "http://<host>:8000/mode/normal"
method: POST
tv2wled_max:
url: "http://<host>:8000/mode/max"
method: POST
tv2wled_stop:
url: "http://<host>:8000/stop"
method: POST
sensor:
- platform: rest
name: tv2wled_status
unique_id: tv2wled_status
resource: "http://<host>:8000/status"
value_template: "{{ value_json.running }}"
json_attributes:
- mode
- pid
- uptime_s
- last_exit_code
### Home Assistant includes, unique_id, UI buttons (our setup)
- `configuration.yaml` (oder passende includes):
```yaml
rest_command: !include rest_commands.yaml
sensor: !include sensors.yaml
binary_sensor: !include binary_sensors.yaml # optional template helper
switch: !include switches.yaml # optional template switchesrest_commands.yaml(Mapping, kein leading-):tv2wled_normal: url: "http://192.168.178.166:8001/mode/normal" method: POST tv2wled_max: url: "http://192.168.178.166:8001/mode/max" method: POST tv2wled_stop: url: "http://192.168.178.166:8001/stop" method: POST
sensors.yaml(Liste, mitunique_id, sonst UI-Warnung):- platform: rest name: tv2wled_status unique_id: tv2wled_status resource: "http://192.168.178.166:8001/status" value_template: "{{ value_json.running }}" json_attributes: - mode - desired_mode - pid - uptime_s - last_exit_code - op_in_progress - op_error - log_lines
- Optional
binary_sensors.yaml:- platform: template sensors: tv2wled_running: friendly_name: "tv2wled running" unique_id: tv2wled_running value_template: "{{ states('sensor.tv2wled_status') in ['True','true','on','1'] }}" icon_template: > {% if is_state('binary_sensor.tv2wled_running','on') %}mdi:led-on{% else %}mdi:led-off{% endif %}
- Smoother Switches (nutzen
desired_mode, schickenstopzum Ausschalten, schneller Poll):- REST-Sensor mit k?rzerem Intervall:
- platform: rest name: tv2wled_status unique_id: tv2wled_status resource: "http://192.168.178.166:8001/status" scan_interval: 2 value_template: "{{ value_json.running }}" json_attributes: - mode - desired_mode - pid - uptime_s - last_exit_code - op_in_progress - op_error - log_lines
- Template-Switches (
switches.yaml), die sofortdesired_modezeigen, perstopausschalten und nur verf?gbar sind, wenn das Ambilight-Licht an ist:- platform: template switches: tv2wled_mode_normal: friendly_name: "tv2wled Normal" unique_id: tv2wled_mode_normal availability: "{{ is_state('light.wohnzimmer_fernseher_ambilight','on') }}" value_template: > {% set d = state_attr('sensor.tv2wled_status','desired_mode') %} {% set m = state_attr('sensor.tv2wled_status','mode') %} {{ d == 'normal' or (d is none and m == 'normal') }} turn_on: { service: rest_command.tv2wled_normal } turn_off: { service: rest_command.tv2wled_stop } tv2wled_mode_max: friendly_name: "tv2wled Max" unique_id: tv2wled_mode_max availability: "{{ is_state('light.wohnzimmer_fernseher_ambilight','on') }}" value_template: > {% set d = state_attr('sensor.tv2wled_status','desired_mode') %} {% set m = state_attr('sensor.tv2wled_status','mode') %} {{ d == 'max' or (d is none and m == 'max') }} turn_on: { service: rest_command.tv2wled_max } turn_off: { service: rest_command.tv2wled_stop }
- REST-Sensor mit k?rzerem Intervall:
- HA neu starten nach ?nderungen. Danach in der UI (Lovelace) Button-Karten anlegen (Dienst
rest_command.tv2wled_normal|max|stop) und eine Entities-Karte mitsensor.tv2wled_status(optionalbinary_sensor.tv2wled_running).
This is a "what we actually did" checklist so the setup can be reproduced later without extra context.
- Docker in an LXC container requires Proxmox CT features like nesting (and often keyctl). If Docker behaves oddly, consider running this in a small VM instead.
Example:
/opt/homelab/docker-compose.yml/opt/homelab/tv2wled/(this repo:Dockerfile,tv2wled.py,tv2wled_api.py, ...)/opt/homelab/tv2wled/config_normal.json(secrets, git-ignored)/opt/homelab/tv2wled/config_max.json(secrets, git-ignored)
Important:
- The Docker build uses the build context. If
docker-compose.ymlis in the parent directory, setbuild.context: ./tv2wledso the Dockerfile and Python files are included. - Config files are mounted as volumes (read-only). They are not copied into the image (see
.dockerignore).
We hit Bind for 0.0.0.0:8000 failed: port is already allocated, so we mapped host port 8001 to container port 8000:
services:
tv2wled:
build:
context: ./tv2wled
container_name: homelab-tv2wled
restart: unless-stopped
ports:
- "8001:8000"
environment:
TV2WLED_DEFAULT_MODE: normal
TV2WLED_AUTOSTART: "1"
TV2WLED_CONFIG_NORMAL: /configs/normal.json
TV2WLED_CONFIG_MAX: /configs/max.json
# optional watchdog without editing configs:
# TV2WLED_EXIT_AFTER_NO_DATA_S: "30"
volumes:
- ./tv2wled/config_normal.json:/configs/normal.json:ro
- ./tv2wled/config_max.json:/configs/max.json:roCommands used:
apt update
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker --version
docker compose versionFrom /opt/homelab (where docker-compose.yml is):
docker compose up -d --build tv2wled
docker compose ps tv2wled
docker compose logs --tail 200 tv2wledForce a rebuild/recreate after changing files:
docker compose up -d --build --force-recreate tv2wledSymptom (during docker compose build):
Temporary failure in name resolutionwhile runningpip install ...
Host DNS looked fine (getent hosts pypi.org worked), but Docker build containers had DNS issues.
Diagnostics commands used:
cat /etc/resolv.conf
getent hosts pypi.org
docker run --rm python:3.11-slim python -c "import socket; print(socket.gethostbyname('pypi.org'))"
docker run --rm python:3.11-slim python -c "import urllib.request; print(urllib.request.urlopen('https://pypi.org/simple/requests/', timeout=10).status)"Fix (set Docker daemon DNS) + restart Docker:
mkdir -p /etc/docker
cat > /etc/docker/daemon.json <<'JSON'
{
"dns": ["1.1.1.1", "8.8.8.8"]
}
JSON
systemctl restart dockerIf you also want local DNS names (e.g. fritz.box) inside containers, include your LAN DNS first:
cat > /etc/docker/daemon.json <<'JSON'
{
"dns": ["192.168.178.166", "1.1.1.1", "8.8.8.8"]
}
JSON
systemctl restart dockerSymptom:
Bind for 0.0.0.0:8000 failed: port is already allocated
Commands used to find the offender:
ss -ltnp | grep ':8000'
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep 8000Fix:
- Use another host port (we used
8001:8000), or stop the other service/container.
FastAPI (this project) returns JSON on /health. If you see plain OK, you are hitting another service.
Test commands:
curl -sS http://192.168.178.166:8001/health
curl -sS http://192.168.178.166:8001/status
curl -sS "http://192.168.178.166:8001/logs?lines=200"
curl -sS -X POST http://192.168.178.166:8001/mode/normal
curl -sS -X POST http://192.168.178.166:8001/mode/max
curl -sS -X POST http://192.168.178.166:8001/stopConvenience GET aliases also exist:
curl -sS http://192.168.178.166:8001/mode/normal
curl -sS http://192.168.178.166:8001/mode/max
curl -sS http://192.168.178.166:8001/stopTwo options:
- Per config file: add a top-level field (same level as
tv_ip):"exit_after_no_data_s": 30
- Or via Docker env without editing configs:
TV2WLED_EXIT_AFTER_NO_DATA_S: "30"
Internally this uses tv2wled.py run --exit-after-no-data 30 and exits the sync process after 30s without Ambilight data.
From repo root (needs Python 3.9+):
pip install -r requirements-api.txt
uvicorn tv2wled_api:app --host 0.0.0.0 --port 8000Then hit http://localhost:8000/health, /status, /mode/normal.
- Added
tv2wled_api.py(FastAPI wrapper, no auth) to start/stop/switchtv2wledbetween two configs. - Added
Dockerfile,docker-compose.yml,requirements-api.txt,.dockerignore. - Updated
tv2wled.pyto support--exit-after-no-data/exit_after_no_data_s. - Updated
.gitignoreto ignoreconfig_*.json(normal/max configs contain secrets).
For debugging or manual flows (not needed when using main.py):
- Request PIN:
This writes
python pair_request.py --tv 192.168.1.50
pair_state.jsonand shows a PIN on the TV. - Grant using the PIN:
This writes
python grant_pair.py --pin 1234
config.json. Then runpython main.py.
config.jsonholds TV credentials, layout, and WLED settings. It’s git-ignored.- Key fields:
tv_ip,base_url,device_id,auth_keylayout:top_leds/right_leds/bottom_leds/left_leds, flips,start_corner,clockwise,offsetwled:host,udp_port(default 21324),timeout_s,fps
- Python 3.9+ recommended
- Philips TV with JointSpace enabled (API v6)
- Network access between TV and host, and between host and WLED