feat: add KEF speaker control command
This commit is contained in:
283
dotfiles/lib/bin/kef-control
Executable file
283
dotfiles/lib/bin/kef-control
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_PATH = Path.home() / ".config" / "kef-control" / "config.json"
|
||||||
|
STATE_DIR = Path.home() / ".local" / "state" / "kef-control"
|
||||||
|
DEFAULT_UNMUTE_VOLUME = 30
|
||||||
|
MODEL_ALIASES = {
|
||||||
|
"LS50W2": "LS50WII",
|
||||||
|
"LS50WII": "LS50WII",
|
||||||
|
}
|
||||||
|
SOURCE_ALIASES = {
|
||||||
|
"aux": "analog",
|
||||||
|
"optical": "optic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_model(model):
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
return MODEL_ALIASES.get(model.upper(), model)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_source(source):
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
return SOURCE_ALIASES.get(source.lower(), source.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path, default):
|
||||||
|
if not path.exists():
|
||||||
|
return default
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
return load_json(CONFIG_PATH, {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config):
|
||||||
|
save_json(CONFIG_PATH, config)
|
||||||
|
|
||||||
|
|
||||||
|
def state_path(host):
|
||||||
|
safe_host = host.replace("/", "_").replace(":", "_")
|
||||||
|
return STATE_DIR / f"{safe_host}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(host):
|
||||||
|
return load_json(state_path(host), {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(host, state):
|
||||||
|
save_json(state_path(host), state)
|
||||||
|
|
||||||
|
|
||||||
|
def remember_volume(host, volume):
|
||||||
|
if volume <= 0:
|
||||||
|
return
|
||||||
|
state = load_state(host)
|
||||||
|
state["last_nonzero_volume"] = volume
|
||||||
|
save_state(host, state)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_volume(host):
|
||||||
|
state = load_state(host)
|
||||||
|
volume = state.get("last_nonzero_volume", DEFAULT_UNMUTE_VOLUME)
|
||||||
|
return max(1, min(100, int(volume)))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_host(args, config, parser):
|
||||||
|
host = args.host or os.getenv("KEF_SPEAKER_IP") or config.get("host")
|
||||||
|
if host:
|
||||||
|
return host
|
||||||
|
parser.error(
|
||||||
|
"speaker host is required; pass --host, set KEF_SPEAKER_IP, or run "
|
||||||
|
"'kef-control config --host <ip> --model LS50WII'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model(args, config):
|
||||||
|
return normalize_model(
|
||||||
|
args.model or os.getenv("KEF_SPEAKER_MODEL") or config.get("model")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_for(args, config, parser):
|
||||||
|
from pykefcontrol.kef_connector import KefConnector
|
||||||
|
|
||||||
|
host = resolve_host(args, config, parser)
|
||||||
|
model = resolve_model(args, config)
|
||||||
|
return host, KefConnector(host, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
def print_json(data):
|
||||||
|
print(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_config(args, config):
|
||||||
|
should_save = args.clear or args.host_value is not None or args.model_value is not None
|
||||||
|
if args.clear:
|
||||||
|
config = {}
|
||||||
|
if args.host_value is not None:
|
||||||
|
config["host"] = args.host_value
|
||||||
|
if args.model_value is not None:
|
||||||
|
config["model"] = normalize_model(args.model_value)
|
||||||
|
if should_save:
|
||||||
|
save_config(config)
|
||||||
|
print_json(config)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_status(connector, host):
|
||||||
|
status = {
|
||||||
|
"host": host,
|
||||||
|
"speaker_name": connector.speaker_name,
|
||||||
|
"speaker_model": connector.speaker_model,
|
||||||
|
"firmware_version": connector.firmware_version,
|
||||||
|
"power_status": connector.status,
|
||||||
|
"source": connector.source,
|
||||||
|
"volume": connector.volume,
|
||||||
|
"is_playing": connector.is_playing,
|
||||||
|
}
|
||||||
|
remember_volume(host, status["volume"])
|
||||||
|
if status["is_playing"]:
|
||||||
|
status["song_info"] = connector.get_song_information()
|
||||||
|
status["song_length_ms"] = connector.song_length
|
||||||
|
status["song_position_ms"] = connector.song_status
|
||||||
|
codec_info = connector.get_audio_codec_information()
|
||||||
|
if codec_info:
|
||||||
|
status["audio_codec"] = codec_info
|
||||||
|
wifi_info = connector.get_wifi_information()
|
||||||
|
if wifi_info:
|
||||||
|
status["wifi"] = wifi_info
|
||||||
|
print_json(status)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_source(connector, host, source):
|
||||||
|
if source is None:
|
||||||
|
print(connector.source)
|
||||||
|
return
|
||||||
|
connector.source = normalize_source(source)
|
||||||
|
print(connector.source)
|
||||||
|
remember_volume(host, connector.volume)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_volume(connector, host, volume):
|
||||||
|
if volume is None:
|
||||||
|
print(connector.volume)
|
||||||
|
return
|
||||||
|
if not 0 <= volume <= 100:
|
||||||
|
raise ValueError("volume must be between 0 and 100")
|
||||||
|
connector.set_volume(volume)
|
||||||
|
if volume > 0:
|
||||||
|
remember_volume(host, volume)
|
||||||
|
print(connector.volume)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(description="Control KEF wireless speakers")
|
||||||
|
parser.add_argument("--host", help="Speaker IP or hostname")
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
help="Optional model override, e.g. LS50WII",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
config_parser = subparsers.add_parser(
|
||||||
|
"config",
|
||||||
|
help="Show or update saved defaults in ~/.config/kef-control/config.json",
|
||||||
|
)
|
||||||
|
config_parser.add_argument("--host", dest="host_value")
|
||||||
|
config_parser.add_argument("--model", dest="model_value")
|
||||||
|
config_parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Clear saved host and model",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers.add_parser("status", help="Show current speaker status as JSON")
|
||||||
|
|
||||||
|
power_parser = subparsers.add_parser("power", help="Turn speaker power on or off")
|
||||||
|
power_parser.add_argument("state", choices=["on", "off"])
|
||||||
|
|
||||||
|
source_parser = subparsers.add_parser(
|
||||||
|
"source",
|
||||||
|
help="Get or set source (wifi, bluetooth, tv, optic, coaxial, analog)",
|
||||||
|
)
|
||||||
|
source_parser.add_argument("source", nargs="?")
|
||||||
|
|
||||||
|
volume_parser = subparsers.add_parser("volume", help="Get or set volume")
|
||||||
|
volume_parser.add_argument("volume", nargs="?", type=int)
|
||||||
|
|
||||||
|
subparsers.add_parser("mute", help="Set volume to 0 and remember the prior level")
|
||||||
|
subparsers.add_parser(
|
||||||
|
"unmute",
|
||||||
|
help="Restore the last remembered non-zero volume level",
|
||||||
|
)
|
||||||
|
subparsers.add_parser("play-pause", help="Toggle play/pause")
|
||||||
|
subparsers.add_parser("next", help="Skip to the next track")
|
||||||
|
subparsers.add_parser("previous", help="Go to the previous track")
|
||||||
|
subparsers.add_parser("song-info", help="Show current song metadata as JSON")
|
||||||
|
subparsers.add_parser("codec-info", help="Show audio codec information as JSON")
|
||||||
|
subparsers.add_parser("wifi-info", help="Show current Wi-Fi information as JSON")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == "config":
|
||||||
|
handle_config(args, config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
host, connector = connector_for(args, config, parser)
|
||||||
|
|
||||||
|
if args.command == "status":
|
||||||
|
handle_status(connector, host)
|
||||||
|
elif args.command == "power":
|
||||||
|
if args.state == "on":
|
||||||
|
connector.power_on()
|
||||||
|
else:
|
||||||
|
connector.shutdown()
|
||||||
|
print(connector.status)
|
||||||
|
elif args.command == "source":
|
||||||
|
handle_source(connector, host, args.source)
|
||||||
|
elif args.command == "volume":
|
||||||
|
handle_volume(connector, host, args.volume)
|
||||||
|
elif args.command == "mute":
|
||||||
|
current_volume = connector.volume
|
||||||
|
remember_volume(host, current_volume)
|
||||||
|
connector.set_volume(0)
|
||||||
|
print(connector.volume)
|
||||||
|
elif args.command == "unmute":
|
||||||
|
connector.set_volume(restore_volume(host))
|
||||||
|
print(connector.volume)
|
||||||
|
elif args.command == "play-pause":
|
||||||
|
connector.toggle_play_pause()
|
||||||
|
print("ok")
|
||||||
|
elif args.command == "next":
|
||||||
|
connector.next_track()
|
||||||
|
print("ok")
|
||||||
|
elif args.command == "previous":
|
||||||
|
connector.previous_track()
|
||||||
|
print("ok")
|
||||||
|
elif args.command == "song-info":
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"is_playing": connector.is_playing,
|
||||||
|
"song_info": connector.get_song_information(),
|
||||||
|
"song_length_ms": connector.song_length,
|
||||||
|
"song_position_ms": connector.song_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif args.command == "codec-info":
|
||||||
|
print_json(connector.get_audio_codec_information())
|
||||||
|
elif args.command == "wifi-info":
|
||||||
|
print_json(connector.get_wifi_information())
|
||||||
|
else:
|
||||||
|
parser.error(f"unsupported command: {args.command}")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"kef-control: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -318,6 +318,8 @@ from transformers import (/' \
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pykefcontrol = final.python3Packages.pykefcontrol;
|
||||||
|
|
||||||
python-with-my-packages = let
|
python-with-my-packages = let
|
||||||
my-python-packages = python-packages:
|
my-python-packages = python-packages:
|
||||||
with python-packages; [
|
with python-packages; [
|
||||||
@@ -329,6 +331,7 @@ from transformers import (/' \
|
|||||||
numpy
|
numpy
|
||||||
openpyxl
|
openpyxl
|
||||||
pip
|
pip
|
||||||
|
pykefcontrol
|
||||||
requests
|
requests
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
@@ -337,6 +340,38 @@ from transformers import (/' \
|
|||||||
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
|
||||||
(
|
(
|
||||||
python-final: python-prev: {
|
python-final: python-prev: {
|
||||||
|
pykefcontrol = python-prev.buildPythonPackage rec {
|
||||||
|
pname = "pykefcontrol";
|
||||||
|
version = "0.9";
|
||||||
|
pyproject = true;
|
||||||
|
|
||||||
|
src = final.fetchFromGitHub {
|
||||||
|
owner = "N0ciple";
|
||||||
|
repo = "pykefcontrol";
|
||||||
|
rev = "530a7d15cf692c35a7c181c8f5e28edc0f1e085a";
|
||||||
|
hash = "sha256-V/uYzzUv/PslfZ/zSSAK4j6kI9lLQOXBN1AG0rjRrpg=";
|
||||||
|
};
|
||||||
|
|
||||||
|
postPatch = ''
|
||||||
|
substituteInPlace pyproject.toml \
|
||||||
|
--replace-fail 'version = "0.8"' 'version = "0.9"'
|
||||||
|
'';
|
||||||
|
|
||||||
|
build-system = with python-final; [ hatchling ];
|
||||||
|
propagatedBuildInputs = with python-final; [
|
||||||
|
aiohttp
|
||||||
|
requests
|
||||||
|
];
|
||||||
|
|
||||||
|
pythonImportsCheck = [ "pykefcontrol" ];
|
||||||
|
|
||||||
|
meta = with final.lib; {
|
||||||
|
description = "Python library for controlling KEF LS50 Wireless II, LSX II, and LS60 speakers";
|
||||||
|
homepage = "https://github.com/N0ciple/pykefcontrol";
|
||||||
|
license = licenses.mit;
|
||||||
|
platforms = platforms.linux ++ platforms.darwin;
|
||||||
|
};
|
||||||
|
};
|
||||||
pysilero-vad = python-prev.pysilero-vad.overridePythonAttrs (_: {
|
pysilero-vad = python-prev.pysilero-vad.overridePythonAttrs (_: {
|
||||||
src = final.fetchFromGitHub {
|
src = final.fetchFromGitHub {
|
||||||
owner = "colonelpanic8";
|
owner = "colonelpanic8";
|
||||||
|
|||||||
Reference in New Issue
Block a user