Files
heos/config.py
T
franzz c153b52641
Deploy HEOS panel / deploy (push) Successful in 28s
Fix data version extraction
2026-09-22 15:53:47 +02:00

151 lines
6.8 KiB
Python

"""Everything you might want to change lives here.
Copy the values out of `GET /api/targets` (or the old `/targets`) the
first time you run this, so the names below match exactly what HEOS
reports for your own devices.
"""
import json
import os
import subprocess
from pathlib import Path
def _load_dotenv(path: Path):
"""A `source .env` a shell might forget to `export` is a whole class of
bug this sidesteps: read the file directly, rather than trusting
whatever the calling shell's environment happens to contain. Existing
environment variables still win, so a real export (or systemd's
EnvironmentFile) overrides the file rather than the other way round."""
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
_load_dotenv(Path(__file__).resolve().parent / ".env")
# --- Rooms, ports & behaviour -------------------------------------------
# None of this is sensitive -- it doesn't give away anything about your
# LAN or credentials -- but it's still yours, not the app's: buying a
# speaker, renaming a room, or changing a port is a config edit, not a
# code change. Lives in config.json rather than here or in .env; copy
# config.json.example to get started.
#
# heos_name : the EXACT name HEOS reports for that player -- including
# an In-Room Group like a stereo pair, which HEOS pairs at
# the hardware level into one player, one pid, always.
# spotify_name : only needed if a room's Spotify Connect name differs
# from heos_name -- GET /api/spotify/devices?account=account1 shows what
# Spotify actually calls it. Defaults to heos_name.
# in_room_group : what heos_name actually names, when it is not a single
# speaker -- one of IN_ROOM_GROUPS below. HEOS merges any
# of these into one player, one pid, at the hardware
# level, the same way it does a Stereo Pair -- so, same
# as heos_name itself, this can only be set by hand, not
# read back from HEOS. Defaults to "none".
IN_ROOM_GROUPS = ("none", "stereo-pair", "lcr-fronts", "surround-sound-system", "subwoofer")
_config_path = Path(__file__).resolve().parent / "config.json"
try:
_cfg = json.loads(_config_path.read_text())
except FileNotFoundError:
raise SystemExit(
f"{_config_path} not found -- copy config.json.example to config.json "
"and fill in your own rooms (see the README's Configure section)."
)
ROOMS = _cfg["rooms"]
for _index, (_key, _room) in enumerate(ROOMS.items()):
_group = _room.get("in_room_group", "none")
if _group not in IN_ROOM_GROUPS:
raise SystemExit(
f"{_config_path}: '{_key}' has in_room_group '{_group}', not one "
f"of {', '.join(IN_ROOM_GROUPS)}"
)
# No 'order' in config.json just means "wherever it falls in rooms".
_room.setdefault("order", _index)
# The AVR is always the group host: its content takes over every room
# that joins, which is the whole point of the merge buttons.
HOST_ROOM = _cfg["host_room"]
# The rooms that get a card with volume + a join/leave button.
GUEST_ROOMS = _cfg["guest_rooms"]
# The grid the volume buttons snap to. A tap moves to the next multiple of
# this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
VOLUME_STEP = _cfg["volume_step"]
# Port the panel itself listens on.
APP_PORT = _cfg["app_port"]
# --- Network ----------------------------------------------------------
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
# whichever unit you connect to can see and control every player and
# group on the network. A LAN address isn't a credential, so it lives
# here rather than in .env -- just don't publish this file if your LAN
# is reachable from outside it.
HEOS_HOST = _cfg["heos_host"]
HEOS_PORT = _cfg["heos_port"]
# --- Zidoo (optional) ---------------------------------------------------
# A Zidoo media player plugged into one of the AVR's inputs. HEOS only
# knows that input is selected, not what the Zidoo is actually showing, so
# its "now playing" comes from the Zidoo's own HTTP API instead -- queried
# only while ZIDOO_INPUT_CODE is the AVR's selected input. Leave
# zidoo_host out of config.json if there is no Zidoo to ask.
ZIDOO_HOST = _cfg.get("zidoo_host")
ZIDOO_PORT = _cfg["zidoo_port"]
ZIDOO_INPUT_CODE = "inputs/mediaplayer" # see GET /api/avr/inputs
# Shown as the app's name on the iOS home screen.
APP_NAME = "Heos"
# The deployed commit, so the UI can tell when the Pi has moved on to a
# newer version and reload itself -- iOS otherwise resumes a suspended
# home-screen session without ever checking back with the server. The
# Gitea deploy stamps VERSION at checkout time and ships it (the deployed
# tree otherwise has no .git -- see .gitea/workflows/deploy.yml); a manual
# git-clone install has no VERSION file, so it falls back to asking git
# directly. Empty when neither is available; app.js treats that as
# "nothing to compare".
def _app_version() -> str:
version_file = Path(__file__).resolve().parent / "VERSION"
if version_file.exists():
return version_file.read_text().strip()
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=Path(__file__).resolve().parent,
capture_output=True, text=True, timeout=1,
)
return result.stdout.strip() if result.returncode == 0 else ""
except OSError:
return ""
APP_VERSION = _app_version()
# --- Spotify (optional) -------------------------------------------------
# One Spotify button per account on each speaker's card (not the AVR's),
# resuming that account's playback there -- see the README's Spotify section and
# tools/spotify_auth.py. Credentials come from .env, never from here, so
# they stay out of git. One developer app serves every account; only the
# refresh token differs, since that is what each account's login produces.
SPOTIFY_CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
SPOTIFY_CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
# key -> button label and that account's refresh token, in button order.
# An account without a token gets no button. The label comes from
# spotify_accounts in config.json -- e.g. "account1": "Fifou".
_spotify_names = _cfg.get("spotify_accounts", {})
SPOTIFY_ACCOUNTS = {
"account1": {"label": _spotify_names.get("account1", "Account 1"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT1_REFRESH_TOKEN")},
"account2": {"label": _spotify_names.get("account2", "Account 2"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT2_REFRESH_TOKEN")},
}