Files
soundtower-controller/controller/gui.py
T
lda Verified 038e152bbc
Checks / tests (ubuntu-latest, 3.11) (push) Canceled after 0s
Checks / tests (ubuntu-latest, 3.14) (push) Canceled after 0s
Checks / tests (windows-latest, 3.11) (push) Canceled after 0s
Checks / tests (windows-latest, 3.14) (push) Canceled after 0s
Checks / package (push) Canceled after 0s
Add MX-T40 BLE desktop controller and protocol documentation
2026-10-25 04:43:47 +07:00

226 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tk interface; Bluetooth workers and protocol code never import this module."""
import queue
import tkinter as tk
from tkinter import colorchooser, ttk
from controller import commands
from controller.ble_link import BLELink
from controller.link import Link
from controller.messages import ActivityLog, describe_reply
from controller.protocol import EQ, LIGHTS
class App:
def __init__(self, root, address="", channel=1, prepare=False, transport="ble"):
self.root = root
self.link = None
self.events = queue.Queue()
self.ready = False
self.channel = channel
self.prepare = prepare
self.transport = transport
self.activity = ActivityLog()
root.title("MX-T40 · Sound Tower")
root.minsize(480, 640)
root.protocol("WM_DELETE_WINDOW", self.close)
style = ttk.Style()
if "vista" in style.theme_names():
style.theme_use("vista")
style.configure("TButton", padding=(10, 6))
outer = ttk.Frame(root, padding=20)
outer.pack(fill="both", expand=True)
ttk.Label(outer, text="MX-T40", font=("Segoe UI", 22, "bold")).pack(anchor="w")
ttk.Label(outer, text="Sound Tower controller", font=("Segoe UI", 11)).pack(
anchor="w", pady=(0, 18)
)
connection = ttk.Frame(outer)
connection.pack(fill="x")
ttk.Label(connection, text="Address / port").pack(side="left")
self.port = ttk.Combobox(connection, width=20)
self.port.set(address)
self.port.pack(side="left", padx=8)
ttk.Button(connection, text="Refresh", command=self.refresh).pack(side="left")
self.connect = ttk.Button(connection, text="Connect", command=self.toggle)
self.connect.pack(side="right")
self.status = tk.StringVar(
value="Enter the speaker’s Bluetooth address. Using Samsung BLE control."
if transport == "ble"
else f"Enter the speaker’s Bluetooth address. Direct RFCOMM channel: {channel}."
)
ttk.Label(outer, textvariable=self.status, wraplength=440).pack(anchor="w", pady=(10, 16))
self.controls = ttk.Frame(outer)
self.controls.pack(fill="x")
self.selection("EQ preset", EQ, commands.set_eq)
self.selection("Bass boost", {"On": True, "Off": False}, commands.set_bass)
self.selection("Lighting mode", LIGHTS, commands.set_lighting)
self.selection("Source", commands.SOURCES, commands.set_source)
row = ttk.Frame(self.controls)
row.pack(fill="x", pady=5)
ttk.Label(row, text="Lighting color", width=17).pack(side="left")
ttk.Button(row, text="Choose color…", command=self.color).pack(side="left")
row = ttk.Frame(self.controls)
row.pack(fill="x", pady=(12, 5))
for label, action in [
("Volume −", "volume_down"),
("Volume +", "volume_up"),
("Mute", "mute"),
("Play / pause", "play_pause"),
]:
ttk.Button(
row, text=label, command=lambda a=action: self.send(commands.remote_key(a))
).pack(side="left", padx=(0, 5))
ttk.Button(
self.controls, text="Read EQ and lighting status", command=self.read_status
).pack(anchor="w", pady=8)
ttk.Label(outer, text="Speaker status", font=("Segoe UI", 11, "bold")).pack(
anchor="w", pady=(16, 4)
)
self.report = tk.StringVar(value="Waiting for a connection.")
ttk.Label(outer, textvariable=self.report, wraplength=440).pack(anchor="w")
activity_header = ttk.Frame(outer)
activity_header.pack(fill="x", pady=(12, 0))
ttk.Label(activity_header, text="Activity", font=("Segoe UI", 11, "bold")).pack(side="left")
self.raw_packets = tk.BooleanVar(value=False)
ttk.Checkbutton(
activity_header,
text="Show raw packets",
variable=self.raw_packets,
command=self.render_log,
).pack(side="right")
self.log = tk.Text(
outer, height=7, width=58, state="disabled", font=("Segoe UI", 10), wrap="word"
)
self.log.pack(fill="both", expand=True, pady=(12, 0))
self.enable(False)
self.refresh()
root.after(60, self.poll)
def selection(self, label, values, build):
row = ttk.Frame(self.controls)
row.pack(fill="x", pady=5)
ttk.Label(row, text=label, width=17).pack(side="left")
choice = ttk.Combobox(row, values=list(values), state="readonly", width=17)
choice.set(next(iter(values)))
choice.pack(side="left", padx=(0, 8))
ttk.Button(row, text="Apply", command=lambda: self.send(build(values[choice.get()]))).pack(
side="right"
)
def enable(self, enabled):
def visit(widget):
for child in widget.winfo_children():
if isinstance(child, ttk.Combobox):
child.configure(state="readonly" if enabled else "disabled")
elif isinstance(child, ttk.Button):
child.configure(state="normal" if enabled else "disabled")
visit(child)
visit(self.controls)
def refresh(self):
try:
from serial.tools import list_ports
ports = [p.device for p in list_ports.comports()]
self.port.configure(values=ports)
except ImportError:
# Direct RFCOMM does not need pyserial. Missing it only removes
# optional COM-port enumeration from the address dropdown.
self.port.configure(values=[])
def toggle(self):
if self.link:
self.link.stop()
self.connect.configure(state="disabled")
self.enable(False)
self.ready = False
self.status.set("Disconnecting…")
return
port = self.port.get().strip()
if not port:
self.status.set("Enter the MX-T40 Bluetooth address, or select its outgoing COM port.")
return
if self.transport == "ble":
self.link = BLELink(port, self.events)
else:
self.link = Link(port, self.events, self.channel, self.prepare)
self.activity.pending.clear()
self.port.configure(state="disabled")
self.connect.configure(text="Disconnect")
self.status.set("Connecting to speaker…")
self.link.start()
def send(self, payload):
if self.ready and self.link:
try:
self.link.commands.put_nowait(payload)
self.status.set("Command queued. Speaker reports appear below when received.")
except queue.Full:
self.status.set("Command queue is full; wait a moment and try again.")
def color(self):
color, _ = colorchooser.askcolor(parent=self.root, title="Lighting color")
if color:
self.send(commands.set_rgb(*(round(v) for v in color)))
# The owner observes the speaker switch to COLOR on RGB writes.
# Ask for the actual mode rather than treating its ACK as proof.
self.send(commands.lighting_status())
def read_status(self):
for payload in commands.status_queries():
self.send(payload)
def poll(self):
log_changed = False
try:
while True:
kind, value = self.events.get_nowait()
if kind in ("status", "error"):
self.status.set(value)
elif kind == "ready":
self.ready = True
self.enable(True)
self.status.set("Speaker handshake complete.")
elif kind == "closed":
self.link = None
self.ready = False
self.enable(False)
self.connect.configure(text="Connect", state="normal")
self.port.configure(state="normal")
if not self.status.get().startswith(("No ", "Malformed", "could", "Install")):
self.status.set("Disconnected. " + self.status.get())
elif kind == "payload":
self.report.set(describe_reply(value))
elif kind in ("log", "frame"):
self.activity.record(kind, value)
log_changed = True
except queue.Empty:
pass
if log_changed:
self.render_log()
self.root.after(60, self.poll)
def render_log(self):
"""Render the same bounded history in English or English plus raw bytes."""
self.log.configure(
state="normal", font=("Consolas", 9) if self.raw_packets.get() else ("Segoe UI", 10)
)
self.log.delete("1.0", "end")
self.log.insert("end", self.activity.text(raw=self.raw_packets.get()))
self.log.see("end")
self.log.configure(state="disabled")
def close(self):
if self.link:
self.link.stop()
self.root.destroy()
def launch(address="", channel=1, prepare=False, transport="ble"):
"""Create and run the Tk window on the calling (main) thread."""
root = tk.Tk()
App(root, address, channel, prepare, transport)
root.mainloop()