Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+858
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Denon AVR-S540BT Bluetooth Remote — Prototype
|
||||
Uses RFCOMM channel 2 (BT SPP 2) as identified from btsnoop capture.
|
||||
Run on Linux with: python3 denon_remote.py
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
import queue
|
||||
import struct
|
||||
import time
|
||||
|
||||
try:
|
||||
import dbus
|
||||
HAS_DBUS = True
|
||||
except ImportError:
|
||||
HAS_DBUS = False
|
||||
|
||||
# ─── Bluetooth device discovery ──────────────────────────────────────────────
|
||||
|
||||
def find_denon_devices():
|
||||
"""Query BlueZ D-Bus for paired devices with 'DENON' in the name."""
|
||||
if not HAS_DBUS:
|
||||
return []
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
results = []
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Device1" in interfaces:
|
||||
dev = interfaces["org.bluez.Device1"]
|
||||
name = str(dev.get("Name", ""))
|
||||
if "DENON" in name.upper():
|
||||
results.append({
|
||||
"mac": str(dev.get("Address")),
|
||||
"name": name,
|
||||
"paired": bool(dev.get("Paired", False)),
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def find_bt_adapter():
|
||||
"""Return the D-Bus object path of the first BlueZ adapter, or None."""
|
||||
if not HAS_DBUS:
|
||||
return None
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Adapter1" in interfaces:
|
||||
return str(path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def discover_denon_devices(duration=8):
|
||||
"""Run BT discovery for `duration` seconds; return unpaired Denon devices.
|
||||
|
||||
Returns list of dicts: {mac, name, path} where path is the D-Bus object path.
|
||||
Must be called from a worker thread (blocks for `duration` seconds).
|
||||
"""
|
||||
if not HAS_DBUS:
|
||||
return []
|
||||
adapter_path = find_bt_adapter()
|
||||
if adapter_path is None:
|
||||
return []
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
adapter = dbus.Interface(
|
||||
bus.get_object("org.bluez", adapter_path),
|
||||
"org.bluez.Adapter1"
|
||||
)
|
||||
try:
|
||||
adapter.StartDiscovery()
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if "InProgress" not in str(e):
|
||||
return []
|
||||
|
||||
time.sleep(duration)
|
||||
|
||||
try:
|
||||
adapter.StopDiscovery()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
results = []
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Device1" in interfaces:
|
||||
dev = interfaces["org.bluez.Device1"]
|
||||
name = str(dev.get("Name", ""))
|
||||
paired = bool(dev.get("Paired", False))
|
||||
if "DENON" in name.upper() and not paired:
|
||||
results.append({
|
||||
"mac": str(dev.get("Address")),
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def pair_and_trust_device(device_path):
|
||||
"""Pair and trust a BlueZ device. Blocks until pairing completes.
|
||||
|
||||
Returns (True, "") on success, (False, error_message) on failure.
|
||||
"""
|
||||
if not HAS_DBUS:
|
||||
return (False, "D-Bus not available")
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
device = dbus.Interface(
|
||||
bus.get_object("org.bluez", device_path),
|
||||
"org.bluez.Device1"
|
||||
)
|
||||
device.Pair()
|
||||
|
||||
props = dbus.Interface(
|
||||
bus.get_object("org.bluez", device_path),
|
||||
"org.freedesktop.DBus.Properties"
|
||||
)
|
||||
props.Set("org.bluez.Device1", "Trusted", dbus.Boolean(True))
|
||||
|
||||
return (True, "")
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if "AlreadyExists" in str(e):
|
||||
return (True, "")
|
||||
return (False, str(e))
|
||||
except Exception as e:
|
||||
return (False, str(e))
|
||||
|
||||
# ─── Protocol constants ───────────────────────────────────────────────────────
|
||||
|
||||
RFCOMM_CHANNEL = 2
|
||||
|
||||
# Commands (PHONE -> AVR)
|
||||
CMD_VOLUME_UP = bytes([0x41, 0x54, 0x07, 0x00, 0x00, 0x00])
|
||||
CMD_VOLUME_DOWN = bytes([0x41, 0x54, 0x07, 0x01, 0x00, 0x00])
|
||||
CMD_GET_SOURCES = bytes([0x41, 0x54, 0x00, 0x04, 0x00, 0x00])
|
||||
CMD_GET_STATUS = bytes([0x41, 0x54, 0x00, 0x06, 0x00, 0x00])
|
||||
CMD_MUTE_ON = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x01, 0xFE])
|
||||
CMD_MUTE_OFF = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_OFF = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_ON = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x01, 0xFE])
|
||||
|
||||
def cmd_select_input(source_byte):
|
||||
# AT 00 07 01 [source] [checksum_placeholder]
|
||||
return bytes([0x41, 0x54, 0x00, 0x07, 0x01, source_byte, 0x00])
|
||||
|
||||
# Known input source codes observed in log (R,S,T,V,W bytes)
|
||||
# Exact label mapping is approximate — adjust after testing
|
||||
INPUT_SOURCES = {
|
||||
0x40: "Bluetooth",
|
||||
0x55: "Media Player", # 'U' — observed in source list, label approximate
|
||||
0x52: "CBL/SAT", # 'R'
|
||||
0x53: "DVD", # 'S'
|
||||
0x54: "Blu-ray", # 'T'
|
||||
0x56: "Game", # 'V'
|
||||
0x57: "AUX", # 'W'
|
||||
}
|
||||
|
||||
# ─── Bluetooth connection ─────────────────────────────────────────────────────
|
||||
|
||||
class DenonBT:
|
||||
def __init__(self, on_message, on_status):
|
||||
self.sock = None
|
||||
self.connected = False
|
||||
self.on_message = on_message # callback(bytes)
|
||||
self.on_status = on_status # callback(str)
|
||||
self._recv_thread = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
def connect(self, mac):
|
||||
self._stop.clear()
|
||||
try:
|
||||
self.on_status("Connecting…")
|
||||
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
|
||||
s.settimeout(10)
|
||||
s.connect((mac, RFCOMM_CHANNEL))
|
||||
s.settimeout(None)
|
||||
self.sock = s
|
||||
self.connected = True
|
||||
self.on_status(f"Connected to {mac}")
|
||||
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
|
||||
self._recv_thread.start()
|
||||
# Request initial state
|
||||
time.sleep(0.3)
|
||||
self.send(CMD_GET_SOURCES)
|
||||
self.send(CMD_GET_STATUS)
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self.on_status(f"Connection failed: {e}")
|
||||
|
||||
def disconnect(self):
|
||||
self._stop.set()
|
||||
self.connected = False
|
||||
if self.sock:
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sock = None
|
||||
self.on_status("Disconnected")
|
||||
|
||||
def send(self, data: bytes):
|
||||
if not self.connected or not self.sock:
|
||||
self.on_status("Not connected")
|
||||
return
|
||||
try:
|
||||
self.sock.sendall(data)
|
||||
self.on_message(data, direction="SENT")
|
||||
except Exception as e:
|
||||
self.on_status(f"Send error: {e}")
|
||||
self.disconnect()
|
||||
|
||||
def _recv_loop(self):
|
||||
buf = b""
|
||||
while not self._stop.is_set() and self.sock:
|
||||
try:
|
||||
chunk = self.sock.recv(256)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# Process all complete AT packets in buffer
|
||||
while len(buf) >= 4:
|
||||
idx = buf.find(b'AT')
|
||||
if idx == -1:
|
||||
buf = b""
|
||||
break
|
||||
if idx > 0:
|
||||
buf = buf[idx:]
|
||||
# Minimum packet is 4 bytes: AT + 2 command bytes
|
||||
# Use length byte at offset 3 if available, else wait for more
|
||||
if len(buf) < 4:
|
||||
break
|
||||
pkt_len = self._packet_length(buf)
|
||||
if pkt_len is None or len(buf) < pkt_len:
|
||||
break
|
||||
pkt = buf[:pkt_len]
|
||||
buf = buf[pkt_len:]
|
||||
self.on_message(pkt, direction="RECV")
|
||||
except Exception:
|
||||
break
|
||||
self.on_status("Disconnected")
|
||||
|
||||
def _packet_length(self, buf):
|
||||
"""Best-effort length detection for AT protocol packets."""
|
||||
if len(buf) < 4:
|
||||
return None
|
||||
cat = buf[2]
|
||||
typ = buf[3]
|
||||
# Known fixed-length packets
|
||||
if cat == 0x07:
|
||||
if typ in (0x00, 0x01): return 6 # vol up/down command
|
||||
if typ == 0x02: return 9 # vol report
|
||||
if typ == 0x03: return 6 # unknown init cmd
|
||||
if typ == 0x1e: return 27 # sound mode (AT + cmd + 0x15 + 0x00 + 20 text + chk)
|
||||
if typ in (0x0b, 0x1d, 0x25): return 7
|
||||
if typ == 0x31: return 14 # firmware/version string
|
||||
if cat == 0x00:
|
||||
if typ == 0x04:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6 # request
|
||||
return 16 # response with sources
|
||||
if typ == 0x06:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6 # request
|
||||
return 75 # status response
|
||||
if typ == 0x07:
|
||||
if len(buf) > 4 and buf[4] == 0x01:
|
||||
return 7 # input select command
|
||||
return 8 # input report
|
||||
if typ == 0x09: return 7
|
||||
if typ == 0x0a: return 7
|
||||
if typ == 0x0b: return 7
|
||||
if typ == 0x0d: return 21 # device info with MAC
|
||||
if typ == 0x0e: return 16 # device name
|
||||
if typ == 0x13: return 7
|
||||
if cat == 0x0d:
|
||||
if typ == 0x08: return 13
|
||||
if typ == 0x0d: return 6
|
||||
if cat == 0x81:
|
||||
if typ == 0x04: return 7
|
||||
if typ == 0x32: return 119 # large volume table
|
||||
if typ == 0x40: return 7
|
||||
if cat == 0x82:
|
||||
if typ == 0x31: return 14
|
||||
# Fallback: read until next AT or 32 bytes
|
||||
next_at = buf.find(b'AT', 2)
|
||||
if next_at > 0:
|
||||
return next_at
|
||||
return min(32, len(buf))
|
||||
|
||||
|
||||
# ─── GUI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
BG = "#0d0d0d"
|
||||
PANEL = "#161616"
|
||||
BORDER = "#2a2a2a"
|
||||
ACCENT = "#e8c97a" # warm gold
|
||||
ACCENT2 = "#4fc3f7" # cool blue
|
||||
TEXT = "#f0ede6"
|
||||
MUTED = "#666"
|
||||
RED = "#e05252"
|
||||
GREEN = "#4caf50"
|
||||
|
||||
class DenonRemoteApp(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Denon AVR-S540BT Remote")
|
||||
self.configure(bg=BG)
|
||||
self.resizable(False, False)
|
||||
|
||||
self._msg_queue = queue.Queue()
|
||||
self._vol = tk.StringVar(value="—")
|
||||
self._vol_raw = None # raw volume byte (0x00-0xFF)
|
||||
self._muted = False
|
||||
self._sound_mode = tk.StringVar(value="—")
|
||||
self._status = tk.StringVar(value="Disconnected")
|
||||
self._sources = {} # byte -> label
|
||||
self._cur_input = None
|
||||
self._devices = [] # list of dicts from find_denon_devices()
|
||||
self._selected_device = None # index into _devices
|
||||
self._unpaired_devices = [] # list of dicts from discover_denon_devices()
|
||||
self._discovering = False
|
||||
self._pairing = False
|
||||
|
||||
self.bt = DenonBT(
|
||||
on_message=self._on_bt_message,
|
||||
on_status=self._on_bt_status,
|
||||
)
|
||||
|
||||
self._build_ui()
|
||||
self._poll_queue()
|
||||
self._scan_devices()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
self._fonts()
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────
|
||||
hdr = tk.Frame(self, bg=BG, pady=18)
|
||||
hdr.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(hdr, text="DENON", font=self.f_brand,
|
||||
bg=BG, fg=ACCENT).pack(side="left")
|
||||
tk.Label(hdr, text=" AVR-S540BT", font=self.f_model,
|
||||
bg=BG, fg=MUTED).pack(side="left", padx=(2, 0))
|
||||
|
||||
# Status dot
|
||||
self._status_dot = tk.Label(hdr, text="●", font=self.f_dot,
|
||||
bg=BG, fg=RED)
|
||||
self._status_dot.pack(side="right")
|
||||
tk.Label(hdr, textvariable=self._status, font=self.f_small,
|
||||
bg=BG, fg=MUTED).pack(side="right", padx=(0, 6))
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Connection panel ──────────────────────────────────────────────────
|
||||
conn = tk.Frame(self, bg=PANEL, padx=20, pady=16)
|
||||
conn.pack(fill="x", padx=16, pady=(12, 0))
|
||||
|
||||
tk.Label(conn, text="DEVICE", font=self.f_label,
|
||||
bg=PANEL, fg=MUTED).grid(row=0, column=0, sticky="w",
|
||||
columnspan=3)
|
||||
|
||||
self._device_frame = tk.Frame(conn, bg=PANEL)
|
||||
self._device_frame.grid(row=1, column=0, pady=(4, 0), sticky="w")
|
||||
|
||||
self._device_label = tk.Label(
|
||||
self._device_frame, text="Scanning…", font=self.f_mono,
|
||||
bg=PANEL, fg=MUTED, anchor="w"
|
||||
)
|
||||
self._device_label.pack(side="left")
|
||||
|
||||
btn_row = tk.Frame(conn, bg=PANEL)
|
||||
btn_row.grid(row=0, column=2, sticky="e")
|
||||
|
||||
self._scan_btn = self._btn(
|
||||
btn_row, "SCAN", self._scan_devices,
|
||||
bg=PANEL, fg=MUTED, font=self.f_small
|
||||
)
|
||||
self._scan_btn.pack(side="left", padx=(0, 4))
|
||||
|
||||
self._scan_pair_btn = self._btn(
|
||||
btn_row, "SCAN & PAIR", self._start_discovery,
|
||||
bg=PANEL, fg=ACCENT2, font=self.f_small
|
||||
)
|
||||
self._scan_pair_btn.pack(side="left")
|
||||
|
||||
self._conn_btn = self._btn(
|
||||
conn, "CONNECT", self._toggle_connect,
|
||||
bg=ACCENT, fg=BG, font=self.f_btn
|
||||
)
|
||||
self._conn_btn.grid(row=1, column=2, padx=(10, 0), pady=(4, 0),
|
||||
ipady=6, ipadx=14)
|
||||
|
||||
self._unpaired_frame = tk.Frame(conn, bg=PANEL)
|
||||
self._unpaired_frame.grid(row=2, column=0, columnspan=3,
|
||||
pady=(8, 0), sticky="w")
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Power panel ──────────────────────────────────────────────────
|
||||
pwr_frame = tk.Frame(self, bg=BG, pady=6)
|
||||
pwr_frame.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(pwr_frame, text="POWER", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
|
||||
self._btn(
|
||||
pwr_frame, "OFF", lambda: self.bt.send(CMD_POWER_OFF),
|
||||
bg=PANEL, fg=RED, font=self.f_btn,
|
||||
).pack(side="right", ipady=5, ipadx=14)
|
||||
|
||||
self._btn(
|
||||
pwr_frame, "ON", lambda: self.bt.send(CMD_POWER_ON),
|
||||
bg=PANEL, fg=GREEN, font=self.f_btn,
|
||||
).pack(side="right", padx=(0, 6), ipady=5, ipadx=14)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Volume panel ──────────────────────────────────────────────────────
|
||||
vol_frame = tk.Frame(self, bg=BG, pady=8)
|
||||
vol_frame.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(vol_frame, text="VOLUME", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(anchor="w")
|
||||
|
||||
vol_ctrl = tk.Frame(vol_frame, bg=BG)
|
||||
vol_ctrl.pack(fill="x", pady=(8, 0))
|
||||
|
||||
self._vol_down_btn = self._btn(
|
||||
vol_ctrl, "▼", lambda: self.bt.send(CMD_VOLUME_DOWN),
|
||||
bg=PANEL, fg=TEXT, font=self.f_arrow,
|
||||
width=4, activebackground=BORDER
|
||||
)
|
||||
self._vol_down_btn.pack(side="left", ipady=12)
|
||||
|
||||
tk.Label(vol_ctrl, textvariable=self._vol, font=self.f_vol,
|
||||
bg=BG, fg=ACCENT, width=6).pack(side="left")
|
||||
|
||||
self._vol_up_btn = self._btn(
|
||||
vol_ctrl, "▲", lambda: self.bt.send(CMD_VOLUME_UP),
|
||||
bg=PANEL, fg=TEXT, font=self.f_arrow,
|
||||
width=4, activebackground=BORDER
|
||||
)
|
||||
self._vol_up_btn.pack(side="left", ipady=12)
|
||||
|
||||
self._mute_btn = self._btn(
|
||||
vol_ctrl, "MUTE", self._toggle_mute,
|
||||
bg=PANEL, fg=MUTED, font=self.f_btn,
|
||||
activebackground=RED
|
||||
)
|
||||
self._mute_btn.pack(side="right", ipady=6, ipadx=14)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Sound mode ────────────────────────────────────────────────────────
|
||||
sm = tk.Frame(self, bg=BG, pady=6)
|
||||
sm.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(sm, text="SOUND MODE", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
tk.Label(sm, textvariable=self._sound_mode, font=self.f_value,
|
||||
bg=BG, fg=ACCENT2).pack(side="right")
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Input sources ─────────────────────────────────────────────────────
|
||||
inp = tk.Frame(self, bg=BG, pady=8)
|
||||
inp.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(inp, text="INPUT", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(anchor="w")
|
||||
|
||||
self._input_frame = tk.Frame(inp, bg=BG)
|
||||
self._input_frame.pack(fill="x", pady=(8, 0))
|
||||
|
||||
# Populated dynamically when sources are received
|
||||
self._input_buttons = {}
|
||||
self._render_inputs(INPUT_SOURCES)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Log ───────────────────────────────────────────────────────────────
|
||||
log_hdr = tk.Frame(self, bg=BG)
|
||||
log_hdr.pack(fill="x", padx=24, pady=(4, 0))
|
||||
tk.Label(log_hdr, text="PACKET LOG", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
self._btn(log_hdr, "CLEAR", self._clear_log,
|
||||
bg=BG, fg=MUTED, font=self.f_small).pack(side="right")
|
||||
|
||||
self._log = tk.Text(
|
||||
self, height=8, font=self.f_mono,
|
||||
bg="#0a0a0a", fg="#3a7a3a", insertbackground=ACCENT,
|
||||
relief="flat", bd=0, state="disabled",
|
||||
highlightthickness=1, highlightbackground=BORDER,
|
||||
padx=8, pady=6
|
||||
)
|
||||
self._log.pack(fill="x", padx=16, pady=(4, 16))
|
||||
self._log.tag_config("sent", foreground="#4fc3f7")
|
||||
self._log.tag_config("recv", foreground="#81c784")
|
||||
self._log.tag_config("info", foreground=MUTED)
|
||||
|
||||
def _fonts(self):
|
||||
self.f_brand = tkfont.Font(family="Courier", size=18, weight="bold")
|
||||
self.f_model = tkfont.Font(family="Courier", size=12)
|
||||
self.f_label = tkfont.Font(family="Courier", size=8, weight="bold")
|
||||
self.f_value = tkfont.Font(family="Courier", size=12)
|
||||
self.f_vol = tkfont.Font(family="Courier", size=32, weight="bold")
|
||||
self.f_btn = tkfont.Font(family="Courier", size=9, weight="bold")
|
||||
self.f_arrow = tkfont.Font(family="Courier", size=14, weight="bold")
|
||||
self.f_mono = tkfont.Font(family="Courier", size=9)
|
||||
self.f_small = tkfont.Font(family="Courier", size=8)
|
||||
self.f_dot = tkfont.Font(family="Courier", size=10)
|
||||
|
||||
def _btn(self, parent, text, cmd, **kw):
|
||||
defaults = dict(
|
||||
text=text, command=cmd,
|
||||
bg=PANEL, fg=TEXT,
|
||||
font=self.f_btn,
|
||||
relief="flat", bd=0,
|
||||
cursor="hand2",
|
||||
activeforeground=ACCENT,
|
||||
activebackground=BORDER,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return tk.Button(parent, **defaults)
|
||||
|
||||
def _divider(self):
|
||||
tk.Frame(self, bg=BORDER, height=1).pack(
|
||||
fill="x", padx=16, pady=6)
|
||||
|
||||
def _render_inputs(self, sources):
|
||||
for w in self._input_frame.winfo_children():
|
||||
w.destroy()
|
||||
self._input_buttons = {}
|
||||
for code, label in sources.items():
|
||||
btn = self._btn(
|
||||
self._input_frame, label,
|
||||
lambda c=code: self._select_input(c),
|
||||
bg=PANEL, fg=MUTED, font=self.f_btn,
|
||||
)
|
||||
btn.pack(side="left", padx=(0, 6), ipady=5, ipadx=8)
|
||||
self._input_buttons[code] = btn
|
||||
if not sources:
|
||||
tk.Label(self._input_frame, text="No sources discovered yet",
|
||||
font=self.f_small, bg=BG, fg=MUTED).pack(side="left")
|
||||
|
||||
def _toggle_mute(self):
|
||||
if self._muted:
|
||||
self.bt.send(CMD_MUTE_OFF)
|
||||
else:
|
||||
self.bt.send(CMD_MUTE_ON)
|
||||
|
||||
def _update_mute_ui(self):
|
||||
if self._muted:
|
||||
self._mute_btn.configure(bg=RED, fg=TEXT, text="MUTED")
|
||||
else:
|
||||
self._mute_btn.configure(bg=PANEL, fg=MUTED, text="MUTE")
|
||||
|
||||
def _select_input(self, code):
|
||||
self.bt.send(cmd_select_input(code))
|
||||
self._highlight_input(code)
|
||||
|
||||
def _highlight_input(self, code):
|
||||
self._cur_input = code
|
||||
for c, btn in self._input_buttons.items():
|
||||
if c == code:
|
||||
btn.configure(fg=ACCENT, bg="#252010",
|
||||
highlightthickness=1,
|
||||
highlightbackground=ACCENT,
|
||||
highlightcolor=ACCENT)
|
||||
else:
|
||||
btn.configure(fg=MUTED, bg=PANEL,
|
||||
highlightthickness=0)
|
||||
|
||||
def _clear_log(self):
|
||||
self._log.configure(state="normal")
|
||||
self._log.delete("1.0", "end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
# ── Device discovery ─────────────────────────────────────────────────────
|
||||
|
||||
def _scan_devices(self):
|
||||
self._devices = find_denon_devices()
|
||||
self._selected_device = 0 if self._devices else None
|
||||
self._render_device_list()
|
||||
|
||||
def _render_device_list(self):
|
||||
for w in self._device_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
if not HAS_DBUS:
|
||||
tk.Label(self._device_frame,
|
||||
text="Install python3-dbus for auto-detection",
|
||||
font=self.f_small, bg=PANEL, fg=RED).pack(side="left")
|
||||
return
|
||||
|
||||
if not self._devices:
|
||||
tk.Label(self._device_frame,
|
||||
text="No paired Denon device — use SCAN & PAIR",
|
||||
font=self.f_small, bg=PANEL, fg=MUTED).pack(side="left")
|
||||
return
|
||||
|
||||
if len(self._devices) == 1:
|
||||
dev = self._devices[0]
|
||||
tk.Label(self._device_frame,
|
||||
text=f"{dev['name']} {dev['mac']}",
|
||||
font=self.f_mono, bg=PANEL, fg=TEXT).pack(side="left")
|
||||
else:
|
||||
for i, dev in enumerate(self._devices):
|
||||
is_sel = (i == self._selected_device)
|
||||
btn = self._btn(
|
||||
self._device_frame,
|
||||
f"{dev['name']}",
|
||||
lambda idx=i: self._pick_device(idx),
|
||||
bg="#252010" if is_sel else PANEL,
|
||||
fg=ACCENT if is_sel else MUTED,
|
||||
font=self.f_btn,
|
||||
)
|
||||
btn.pack(side="left", padx=(0, 6), ipady=3, ipadx=6)
|
||||
|
||||
def _pick_device(self, idx):
|
||||
self._selected_device = idx
|
||||
self._render_device_list()
|
||||
|
||||
# ── Bluetooth discovery & pairing ─────────────────────────────────────────
|
||||
|
||||
def _start_discovery(self):
|
||||
if not HAS_DBUS:
|
||||
self._log_append("Install python3-dbus for Bluetooth scanning", "info")
|
||||
return
|
||||
if self._discovering:
|
||||
return
|
||||
self._discovering = True
|
||||
self._unpaired_devices = []
|
||||
self._scan_pair_btn.configure(state="disabled", text="SCANNING…")
|
||||
self._render_unpaired_list()
|
||||
self._log_append("Starting Bluetooth discovery…", "info")
|
||||
threading.Thread(target=self._discovery_worker, daemon=True).start()
|
||||
|
||||
def _discovery_worker(self):
|
||||
devices = discover_denon_devices(duration=8)
|
||||
self._msg_queue.put(("discovery_done", devices))
|
||||
|
||||
def _handle_discovery_done(self, devices):
|
||||
self._discovering = False
|
||||
self._unpaired_devices = devices
|
||||
self._scan_pair_btn.configure(state="normal", text="SCAN & PAIR")
|
||||
self._render_unpaired_list()
|
||||
count = len(devices)
|
||||
if count == 0:
|
||||
self._log_append("Discovery complete — no unpaired Denon devices found", "info")
|
||||
else:
|
||||
self._log_append(f"Discovery complete — found {count} unpaired Denon device(s)", "info")
|
||||
|
||||
def _render_unpaired_list(self):
|
||||
for w in self._unpaired_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
if self._discovering:
|
||||
tk.Label(self._unpaired_frame,
|
||||
text="Scanning for Denon devices…",
|
||||
font=self.f_small, bg=PANEL, fg=ACCENT2).pack(anchor="w")
|
||||
return
|
||||
|
||||
if not self._unpaired_devices:
|
||||
return
|
||||
|
||||
tk.Label(self._unpaired_frame,
|
||||
text="UNPAIRED DEVICES", font=self.f_label,
|
||||
bg=PANEL, fg=MUTED).pack(anchor="w", pady=(4, 2))
|
||||
|
||||
for dev in self._unpaired_devices:
|
||||
row = tk.Frame(self._unpaired_frame, bg=PANEL)
|
||||
row.pack(fill="x", pady=1)
|
||||
|
||||
tk.Label(row, text=f"{dev['name']} {dev['mac']}",
|
||||
font=self.f_small, bg=PANEL, fg=MUTED).pack(side="left")
|
||||
|
||||
self._btn(
|
||||
row, "PAIR",
|
||||
lambda d=dev: self._start_pair(d),
|
||||
bg=ACCENT2, fg=BG, font=self.f_small,
|
||||
).pack(side="right", ipadx=8, ipady=2)
|
||||
|
||||
def _start_pair(self, device):
|
||||
if self._pairing:
|
||||
return
|
||||
self._pairing = True
|
||||
self._log_append(f"Pairing with {device['name']} ({device['mac']})…", "info")
|
||||
for w in self._unpaired_frame.winfo_children():
|
||||
for child in w.winfo_children():
|
||||
if isinstance(child, tk.Button):
|
||||
child.configure(state="disabled")
|
||||
threading.Thread(
|
||||
target=self._pair_worker, args=(device,), daemon=True
|
||||
).start()
|
||||
|
||||
def _pair_worker(self, device):
|
||||
success, error = pair_and_trust_device(device["path"])
|
||||
self._msg_queue.put(("pair_done", device, success, error))
|
||||
|
||||
def _handle_pair_done(self, device, success, error):
|
||||
self._pairing = False
|
||||
if success:
|
||||
self._log_append(f"Paired and trusted: {device['name']}", "info")
|
||||
self._unpaired_devices = [
|
||||
d for d in self._unpaired_devices if d["mac"] != device["mac"]
|
||||
]
|
||||
self._render_unpaired_list()
|
||||
self._scan_devices()
|
||||
else:
|
||||
self._log_append(f"Pairing failed: {error}", "info")
|
||||
self._render_unpaired_list()
|
||||
|
||||
# ── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self.bt.connected:
|
||||
self.bt.disconnect()
|
||||
self._conn_btn.configure(text="CONNECT", bg=ACCENT, fg=BG)
|
||||
else:
|
||||
if self._selected_device is None or not self._devices:
|
||||
self._log_append("No paired Denon device — use SCAN & PAIR", "info")
|
||||
return
|
||||
mac = self._devices[self._selected_device]["mac"]
|
||||
self._conn_btn.configure(text="DISCONNECT", bg=RED, fg=TEXT)
|
||||
threading.Thread(
|
||||
target=self.bt.connect, args=(mac,), daemon=True
|
||||
).start()
|
||||
|
||||
# ── Message handling ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_bt_message(self, data: bytes, direction: str):
|
||||
self._msg_queue.put(("msg", data, direction))
|
||||
|
||||
def _on_bt_status(self, msg: str):
|
||||
self._msg_queue.put(("status", msg))
|
||||
|
||||
def _poll_queue(self):
|
||||
try:
|
||||
while True:
|
||||
item = self._msg_queue.get_nowait()
|
||||
if item[0] == "status":
|
||||
self._handle_status(item[1])
|
||||
elif item[0] == "msg":
|
||||
self._handle_message(item[1], item[2])
|
||||
elif item[0] == "discovery_done":
|
||||
self._handle_discovery_done(item[1])
|
||||
elif item[0] == "pair_done":
|
||||
self._handle_pair_done(item[1], item[2], item[3])
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.after(50, self._poll_queue)
|
||||
|
||||
def _handle_status(self, msg: str):
|
||||
self._status.set(msg)
|
||||
connected = msg.startswith("Connected")
|
||||
self._status_dot.configure(fg=GREEN if connected else RED)
|
||||
if not connected:
|
||||
self._conn_btn.configure(text="CONNECT", bg=ACCENT, fg=BG)
|
||||
|
||||
def _handle_message(self, data: bytes, direction: str):
|
||||
hex_str = " ".join(f"{b:02x}" for b in data)
|
||||
tag = "sent" if direction == "SENT" else "recv"
|
||||
arrow = "→" if direction == "SENT" else "←"
|
||||
self._log_append(f"{arrow} {hex_str}", tag)
|
||||
|
||||
if len(data) < 3:
|
||||
return
|
||||
|
||||
cat = data[2]
|
||||
typ = data[3] if len(data) > 3 else None
|
||||
|
||||
# Volume report: AT 07 02 03 c5 [vol] ...
|
||||
if cat == 0x07 and typ == 0x02 and len(data) >= 7:
|
||||
if data[4] == 0x03 and data[5] == 0xc5:
|
||||
vol = data[6]
|
||||
self._vol_raw = vol
|
||||
# Display as dB: raw/2 gives the display value shown on the AVR
|
||||
vol_db = vol / 2.0
|
||||
self._vol.set(f"{vol_db:g}")
|
||||
return
|
||||
|
||||
# Mute state: AT 07 1d 01 [state] ...
|
||||
if cat == 0x07 and typ == 0x1d and len(data) >= 6:
|
||||
self._muted = (data[4] == 0x01 and data[5] == 0x01)
|
||||
self._update_mute_ui()
|
||||
return
|
||||
|
||||
# Sound mode: AT 07 1e ...
|
||||
if cat == 0x07 and typ == 0x1e and len(data) >= 6:
|
||||
text_bytes = data[5:]
|
||||
text = bytes(b for b in text_bytes if 32 <= b < 127).decode("ascii", errors="replace").strip()
|
||||
if text:
|
||||
self._sound_mode.set(text)
|
||||
return
|
||||
|
||||
# Input sources: AT 00 04 0a ...
|
||||
if cat == 0x00 and typ == 0x04 and direction == "RECV" and len(data) >= 8:
|
||||
raw = data[4:]
|
||||
discovered = {}
|
||||
for b in raw:
|
||||
if b in INPUT_SOURCES:
|
||||
discovered[b] = INPUT_SOURCES[b]
|
||||
elif 0x40 <= b <= 0x7f:
|
||||
discovered[b] = f"SRC-{b:02x}"
|
||||
if discovered:
|
||||
self._sources = discovered
|
||||
self._render_inputs(discovered)
|
||||
return
|
||||
|
||||
# Current input: AT 00 07 02 [source] ...
|
||||
if cat == 0x00 and typ == 0x07 and direction == "RECV" and len(data) >= 5:
|
||||
if data[4] == 0x02 and len(data) >= 6:
|
||||
self._highlight_input(data[5])
|
||||
return
|
||||
|
||||
# Device name: AT 00 0e ...
|
||||
if cat == 0x00 and typ == 0x0e and direction == "RECV" and len(data) >= 6:
|
||||
name_bytes = data[5:]
|
||||
name = bytes(b for b in name_bytes if 32 <= b < 127).decode("ascii", errors="replace").strip()
|
||||
if name:
|
||||
self._log_append(f" Device: {name}", "info")
|
||||
return
|
||||
|
||||
def _log_append(self, text: str, tag: str = ""):
|
||||
self._log.configure(state="normal")
|
||||
self._log.insert("end", text + "\n", tag)
|
||||
self._log.see("end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
|
||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = DenonRemoteApp()
|
||||
app.mainloop()
|
||||
Reference in New Issue
Block a user