Add MX-T40 BLE desktop controller and protocol documentation
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
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
This commit is contained in:
38 files changed
+2344
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
name: Checks
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
python: ['3.11', '3.14']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- run: python -m unittest discover -s tests -v
|
||||||
|
- run: python -m controller --help
|
||||||
|
package:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
- run: python -m pip install 'ruff>=0.15,<0.16' build
|
||||||
|
- run: ruff check controller tests scripts
|
||||||
|
- run: ruff format --check controller tests scripts
|
||||||
|
- run: python -m build
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Local reverse-engineering inputs and vendor-owned artifacts.
|
||||||
|
*.bin
|
||||||
|
*.apk
|
||||||
|
*.apkm
|
||||||
|
*.zip
|
||||||
|
/_*.extracted/
|
||||||
|
/ahh/
|
||||||
|
/analysis/
|
||||||
|
/uncompressed_text.txt
|
||||||
|
/.agents/
|
||||||
|
/.aws/
|
||||||
|
/.codex/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Use `uv run --group dev` for tests and formatting; the README lists the commands.
|
||||||
|
Keep Tk imports in `gui.py` and protocol behavior in the shared session. Add
|
||||||
|
comments for observed firmware quirks and distinguish evidence from inference.
|
||||||
|
|
||||||
|
For a protocol change, include a small packet fixture and explain the model,
|
||||||
|
transport, requested action, and observed result. Distinguish a packet ACK from
|
||||||
|
an actual state report. Never add real Bluetooth addresses or phone identifiers
|
||||||
|
to tests; use a synthetic address such as `12:34:56:78:9A:BC`.
|
||||||
|
|
||||||
|
Do not commit firmware, APKs, decompiled vendor code, extracted configuration
|
||||||
|
files, or full personal Bluetooth traces. Document protocol facts in original
|
||||||
|
words and include only the small packet examples needed to reproduce a finding.
|
||||||
|
|
||||||
|
Compatibility reports should state speaker model/firmware, Windows version,
|
||||||
|
Python version, whether audio playback also worked, and the relevant redacted
|
||||||
|
activity log. Tests using fake transports are useful but are not hardware
|
||||||
|
compatibility evidence.
|
||||||
|
|
||||||
|
Before publishing, choose a license and initialize Git in the clean exported
|
||||||
|
folder. GitHub/Gitea repository creation and remote configuration are separate
|
||||||
|
from local export; the exporter never uploads anything.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
include README.md CONTRIBUTING.md pyproject.toml requirements*.txt .gitignore
|
||||||
|
recursive-include docs *.md
|
||||||
|
recursive-include scripts *.py
|
||||||
|
recursive-include tests *.py
|
||||||
|
recursive-include .github *.yml
|
||||||
|
prune analysis
|
||||||
|
prune ahh
|
||||||
|
prune dist
|
||||||
|
prune build
|
||||||
|
global-exclude __pycache__ *.py[cod] *.bin *.apk *.apkm *.zip
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Sound Tower Controller
|
||||||
|
|
||||||
|
A small, unofficial desktop remote for Samsung Sound Tower speakers. Control
|
||||||
|
EQ, bass, lighting, RGB color, volume, mute, playback, and source selection
|
||||||
|
through Bluetooth Low Energy.
|
||||||
|
|
||||||
|
Verified interactively on one **Samsung MX-T40 with Windows**. Other models
|
||||||
|
and operating systems have not been verified. No firmware flashing, Samsung
|
||||||
|
account, or bundled vendor software is required.
|
||||||
|
|
||||||
|
## Run with uv
|
||||||
|
|
||||||
|
Use Windows Python for a Windows Bluetooth adapter. From WSL, invoke `uv.exe`
|
||||||
|
so Bluetooth runs on Windows rather than inside WSL.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run soundtower --address YOUR_SPEAKER_MAC
|
||||||
|
```
|
||||||
|
|
||||||
|
For WSL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv.exe run soundtower --address YOUR_SPEAKER_MAC
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `YOUR_SPEAKER_MAC` with the Bluetooth address of your speaker. Keep it
|
||||||
|
powered on, close the phone app, and click **Connect**. The address can also be
|
||||||
|
entered in the window. `uv run python -m controller` remains supported, as does
|
||||||
|
the earlier `--with-requirements requirements.txt` launch command.
|
||||||
|
|
||||||
|
Python 3.11 or newer is required; physical device testing used Windows Python
|
||||||
|
3.14. The interpreter needs Tcl/Tk. If the interpreter provided by uv lacks Tk,
|
||||||
|
use a Windows Python installation with Tk support, for example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --python C:\Path\To\Python\python.exe soundtower --address YOUR_SPEAKER_MAC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- EQ: Party, Standard, Hip-hop, EDM, Rock, Latin, House, Reggae.
|
||||||
|
- Bass boost, volume up/down, mute, play/pause, and audio source.
|
||||||
|
- Lighting: Off, Ambient, Party, Dance, Thunder, Star, Lover, Color.
|
||||||
|
- RGB color picker; lighting mode is queried after a color change.
|
||||||
|
- Readable activity log with optional raw packet display.
|
||||||
|
|
||||||
|
A command acknowledgment means the packet was received. A later status report
|
||||||
|
confirms the reported setting; the log distinguishes the two. Choosing an RGB
|
||||||
|
color switches the tested speaker to COLOR mode. A play/pause event does not
|
||||||
|
reveal the absolute playback state.
|
||||||
|
|
||||||
|
The source menu includes protocol options that may not exist on every model.
|
||||||
|
The tested MX-T40 reports Bluetooth, USB 1, AUX 1, and AUX 2.
|
||||||
|
|
||||||
|
## Audio versus control
|
||||||
|
|
||||||
|
This app opens the BLE remote-control connection. Windows establishes the
|
||||||
|
separate audio playback connection. Neither a BLE connection nor Windows'
|
||||||
|
word “Connected” guarantees an audio output is available.
|
||||||
|
|
||||||
|
If the speaker disappears from Windows' output list, disconnect/reconnect it
|
||||||
|
through Windows and select it as the sound output. Both connection orders
|
||||||
|
have worked in testing; no fixed startup order is established. Once playback
|
||||||
|
works, the BLE controller can operate alongside it.
|
||||||
|
|
||||||
|
The older RFCOMM/COM path is experimental and was unreliable on this MX-T40:
|
||||||
|
`--transport rfcomm` selects it. BLE is the default.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --group dev python -m unittest discover -s tests -v
|
||||||
|
uv run --group dev ruff check controller tests scripts
|
||||||
|
uv run --group dev ruff format --check controller tests scripts
|
||||||
|
uv build
|
||||||
|
```
|
||||||
|
|
||||||
|
The first online `uv run` resolves dependencies and creates `uv.lock`; commit
|
||||||
|
that lockfile after resolving it for the project. A lockfile is not yet bundled
|
||||||
|
because dependency resolution was unavailable in the offline development shell.
|
||||||
|
|
||||||
|
The tests use captured packet fixtures and fake transports; they do not need
|
||||||
|
a speaker, Tk, or a display. They do not replace physical compatibility tests.
|
||||||
|
The optional GitHub Actions workflow runs headless tests on Windows/Linux and
|
||||||
|
Python 3.11/3.14. The same commands can be used in Gitea CI.
|
||||||
|
|
||||||
|
See [architecture](docs/ARCHITECTURE.md), [protocol](docs/PROTOCOL.md),
|
||||||
|
[feature research](docs/FEATURES.md), and [contributing](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
List the MX-T40's advertised BLE/GATT endpoints without sending controls:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python -m controller.ble_probe
|
||||||
|
```
|
||||||
|
|
||||||
|
Experimental Windows RFCOMM discovery:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --extra diagnostics python -m controller.probe --channel 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preparing a repository
|
||||||
|
|
||||||
|
This development folder also contains private research inputs. Export only the
|
||||||
|
controller, tests, and original documentation into a **new, empty directory**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/export_project.py dist/soundtower-controller
|
||||||
|
```
|
||||||
|
|
||||||
|
The export uses an explicit allowlist. Firmware, APKs, extracted vendor files,
|
||||||
|
decompiled Java, environment files, and caches are excluded. Initialize Git in
|
||||||
|
that exported directory before adding a GitHub or Gitea remote. No remote is
|
||||||
|
configured and nothing is uploaded automatically.
|
||||||
|
|
||||||
|
## Project status
|
||||||
|
|
||||||
|
This is an independent interoperability project, not an official Samsung or
|
||||||
|
Qualcomm product. It contains original controller code and protocol notes,
|
||||||
|
not redistributed firmware or application code. A project license has not yet
|
||||||
|
been selected; no open-source license grant is currently declared.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""MX-T40 desktop controller."""
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Preserve `python -m controller` alongside the installed soundtower command."""
|
||||||
|
|
||||||
|
from controller.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Samsung FFA0 BLE transport using the endpoints observed on the MX-T40."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
|
||||||
|
from controller.session import Session
|
||||||
|
from controller.worker import ConnectionWorker
|
||||||
|
|
||||||
|
SERVICE = "0000ffa0-0000-1000-8000-00805f9b34fb"
|
||||||
|
WRITE = "0000ffa1-0000-1000-8000-00805f9b34fb"
|
||||||
|
NOTIFY = "0000ffa2-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
|
||||||
|
class BLELink(ConnectionWorker):
|
||||||
|
"""Own Bleak's event loop in a worker; Tk communicates only via queues."""
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
asyncio.run(self.run_ble())
|
||||||
|
|
||||||
|
async def run_ble(self):
|
||||||
|
from bleak import BleakClient, BleakScanner
|
||||||
|
|
||||||
|
def report(text):
|
||||||
|
self.events.put(("status", text))
|
||||||
|
self.events.put(("log", text))
|
||||||
|
|
||||||
|
report(f"Scanning for BLE speaker {self.port}…")
|
||||||
|
device = await BleakScanner.find_device_by_address(self.port, timeout=15)
|
||||||
|
if self.stopped.is_set():
|
||||||
|
return
|
||||||
|
if device is None:
|
||||||
|
raise ConnectionError(
|
||||||
|
"No BLE advertisement from this address. Close the phone app and check the speaker is on."
|
||||||
|
)
|
||||||
|
received = asyncio.Queue(maxsize=256)
|
||||||
|
overflow = asyncio.Event()
|
||||||
|
|
||||||
|
def notified(characteristic, data):
|
||||||
|
# Notifications can split one packet or combine several packets;
|
||||||
|
# Decoder retains framing state across callback boundaries.
|
||||||
|
try:
|
||||||
|
received.put_nowait(bytes(data))
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
overflow.set()
|
||||||
|
|
||||||
|
report("Connecting to speaker…")
|
||||||
|
async with BleakClient(device, timeout=20) as client:
|
||||||
|
service = client.services.get_service(SERVICE)
|
||||||
|
if service is None:
|
||||||
|
raise ConnectionError("Speaker does not expose Samsung FFA0 service.")
|
||||||
|
write = service.get_characteristic(WRITE)
|
||||||
|
notify = service.get_characteristic(NOTIFY)
|
||||||
|
if (
|
||||||
|
write is None
|
||||||
|
or notify is None
|
||||||
|
or "write" not in write.properties
|
||||||
|
or "notify" not in notify.properties
|
||||||
|
):
|
||||||
|
raise ConnectionError(
|
||||||
|
"Samsung FFA1 write / FFA2 notify endpoints are missing or incompatible."
|
||||||
|
)
|
||||||
|
report("Listening for speaker updates…")
|
||||||
|
await asyncio.wait_for(client.start_notify(notify, notified), 10)
|
||||||
|
if self.stopped.is_set():
|
||||||
|
return
|
||||||
|
# Samsung's parser ignores receive checksums. Preserve framed BLE
|
||||||
|
# replies with unknown checksums and log them for investigation;
|
||||||
|
# observed checksum variants are recognized by Decoder.
|
||||||
|
session = Session(strict_checksums=False)
|
||||||
|
|
||||||
|
async def send(packet):
|
||||||
|
# All currently exposed commands fit in the baseline ATT
|
||||||
|
# payload of 20 bytes. Do not guess a fragmentation scheme.
|
||||||
|
if len(packet) > 20:
|
||||||
|
raise ValueError("BLE packet exceeds the verified 20-byte command limit.")
|
||||||
|
self.events.put(("log", "TX BLE " + packet.hex(" ")))
|
||||||
|
await asyncio.wait_for(client.write_gatt_char(write, packet, response=True), 10)
|
||||||
|
|
||||||
|
report("Connected; identifying speaker…")
|
||||||
|
await send(session.start())
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
last_sent = time.monotonic()
|
||||||
|
while not self.stopped.is_set():
|
||||||
|
if not client.is_connected:
|
||||||
|
raise ConnectionError("The speaker closed the BLE connection.")
|
||||||
|
if overflow.is_set():
|
||||||
|
raise BufferError(
|
||||||
|
"BLE notification queue overflow; disconnecting rather than losing framing."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(received.get(), 0.05)
|
||||||
|
except TimeoutError:
|
||||||
|
chunk = b""
|
||||||
|
if chunk:
|
||||||
|
self.events.put(("log", "RX BLE " + chunk.hex(" ")))
|
||||||
|
update = session.receive(chunk)
|
||||||
|
self.publish(update)
|
||||||
|
for packet in update.outgoing:
|
||||||
|
await send(packet)
|
||||||
|
last_sent = time.monotonic()
|
||||||
|
if not session.ready and time.monotonic() > deadline:
|
||||||
|
raise TimeoutError(
|
||||||
|
"Samsung BLE endpoints connected, but the app handshake did not complete. Keep the TX/RX log."
|
||||||
|
)
|
||||||
|
if session.ready and time.monotonic() - last_sent >= 0.1:
|
||||||
|
try:
|
||||||
|
payload = self.commands.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
await send(session.command(payload))
|
||||||
|
last_sent = time.monotonic()
|
||||||
|
# Bleak's context manager disconnects and stops notifications on
|
||||||
|
# exit, including handshake errors and an explicit GUI disconnect.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Inspect MX-T40 BLE services without reading/writing control values."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# MX-T40 model advertisements from the Android APK's strings.xml. These
|
||||||
|
# identify discovery records; they are not assumed to be writable services.
|
||||||
|
MODEL_UUIDS = {
|
||||||
|
f"0000{short}-0000-1000-8000-00805f9b34fb" for short in ("ff40", "fe40", "ff41", "fe41")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_candidate(device, advertisement):
|
||||||
|
names = f"{device.name or ''} {advertisement.local_name or ''}".upper()
|
||||||
|
return "T40" in names or bool(
|
||||||
|
MODEL_UUIDS.intersection(value.lower() for value in advertisement.service_uuids)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect(timeout):
|
||||||
|
from bleak import BleakClient, BleakScanner
|
||||||
|
|
||||||
|
print(f"Scanning BLE advertisements for {timeout:g} seconds…", flush=True)
|
||||||
|
devices = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
||||||
|
candidates = [(device, adv) for device, adv in devices.values() if is_candidate(device, adv)]
|
||||||
|
print(f"Nearby BLE devices: {len(devices)}; MX-T40 candidates: {len(candidates)}")
|
||||||
|
if not candidates:
|
||||||
|
print("No matching advertisement. This does not rule out BLE support.")
|
||||||
|
print("Named nearby devices:")
|
||||||
|
for device, adv in devices.values():
|
||||||
|
name = adv.local_name or device.name
|
||||||
|
if name:
|
||||||
|
print(f" {name} [{device.address}]")
|
||||||
|
return 1
|
||||||
|
failures = False
|
||||||
|
for device, adv in candidates:
|
||||||
|
print(f"\nDevice: {adv.local_name or device.name} [{device.address}]")
|
||||||
|
print("Advertised services:", ", ".join(adv.service_uuids) or "(none)")
|
||||||
|
try:
|
||||||
|
# Use the scanned device object so Bleak retains Windows address
|
||||||
|
# type information; the classic audio MAC may not be the BLE MAC.
|
||||||
|
async with BleakClient(device, timeout=15) as client:
|
||||||
|
for service in client.services:
|
||||||
|
print(f"Service: {service.uuid} {service.description}")
|
||||||
|
for char in service.characteristics:
|
||||||
|
print(f" Characteristic: {char.uuid} [{', '.join(char.properties)}]")
|
||||||
|
except Exception as exc:
|
||||||
|
failures = True
|
||||||
|
print(f"GATT inspection failed: {type(exc).__name__}: {exc}")
|
||||||
|
print("\nNo control values were read or written; no notifications enabled.")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--timeout", type=float, default=15)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
raise SystemExit(asyncio.run(inspect(args.timeout)))
|
||||||
|
except ImportError:
|
||||||
|
print("Run: uv.exe run --python 3.14 --with bleak -m controller.ble_probe")
|
||||||
|
raise SystemExit(2)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"BLE scan failed: {type(exc).__name__}: {exc}")
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Command-line entry point; --help works without Tk or Bluetooth installed."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description="Samsung Sound Tower desktop controller")
|
||||||
|
parser.add_argument(
|
||||||
|
"--address", default="", help="Speaker Bluetooth MAC address, or an outgoing COM port"
|
||||||
|
)
|
||||||
|
parser.add_argument("--channel", type=int, default=1, choices=range(1, 31))
|
||||||
|
parser.add_argument(
|
||||||
|
"--prepare",
|
||||||
|
action="store_true",
|
||||||
|
help="Experimental: initialize Windows Bluetooth before RFCOMM",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--transport",
|
||||||
|
choices=("ble", "rfcomm"),
|
||||||
|
default="ble",
|
||||||
|
help="Control transport (default: Samsung BLE FFA0)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
# Keep Tk out of the CLI import path, headless tests, and package consumers.
|
||||||
|
from controller.gui import launch
|
||||||
|
|
||||||
|
launch(args.address, args.channel, args.prepare, args.transport)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Build control payloads without exposing packet layout to the GUI.
|
||||||
|
|
||||||
|
Framing and sequence numbers belong to Session. These helpers describe user
|
||||||
|
actions and return payloads only; they do not connect or send anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from controller.protocol import EQ, LIGHTS
|
||||||
|
|
||||||
|
SOURCES = {"Bluetooth": 1, "USB 1": 2, "USB 2": 3, "AUX 1": 4, "AUX 2": 5}
|
||||||
|
KEYS = {"volume_up": 15, "volume_down": 16, "play_pause": 17, "mute": 20}
|
||||||
|
|
||||||
|
|
||||||
|
def set_eq(preset):
|
||||||
|
if preset not in EQ.values():
|
||||||
|
raise ValueError("Unknown EQ preset")
|
||||||
|
return bytes((0x40, 3, 0, preset, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def set_bass(enabled):
|
||||||
|
# Samsung's bass flag is inverted: 0 means on, 1 means off.
|
||||||
|
return bytes((0x40, 4, 0, 0 if enabled else 1, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def set_lighting(mode):
|
||||||
|
if mode not in LIGHTS.values():
|
||||||
|
raise ValueError("Unknown lighting mode")
|
||||||
|
return bytes((0x50, 3, mode))
|
||||||
|
|
||||||
|
|
||||||
|
def set_source(source):
|
||||||
|
if source not in SOURCES.values():
|
||||||
|
raise ValueError("Unknown source")
|
||||||
|
return bytes((0x30, source))
|
||||||
|
|
||||||
|
|
||||||
|
def set_rgb(red, green, blue):
|
||||||
|
if any(type(value) is not int or not 0 <= value <= 255 for value in (red, green, blue)):
|
||||||
|
raise ValueError("RGB values must be integers between 0 and 255")
|
||||||
|
return bytes((0x60, 2, red, green, blue))
|
||||||
|
|
||||||
|
|
||||||
|
def remote_key(action):
|
||||||
|
try:
|
||||||
|
return bytes((0x70, KEYS[action]))
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError("Unknown remote action") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def lighting_status():
|
||||||
|
return b"\x52\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def status_queries():
|
||||||
|
return b"\x42\x03", lighting_status()
|
||||||
|
|
||||||
|
|
||||||
|
def initial_queries():
|
||||||
|
return b"\x32", b"\x42\x03"
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Experimental RFCOMM/serial adapter for the shared Samsung session."""
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
|
||||||
|
from controller.session import Session
|
||||||
|
from controller.transport import open_stream
|
||||||
|
from controller.worker import ConnectionWorker
|
||||||
|
|
||||||
|
|
||||||
|
class Link(ConnectionWorker):
|
||||||
|
def __init__(self, port, events, channel=1, prepare=False):
|
||||||
|
super().__init__(port, events)
|
||||||
|
self.channel = channel
|
||||||
|
self.prepare = prepare
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
self.events.put(("log", f"Opening {self.port} (direct RFCOMM uses channel {self.channel})"))
|
||||||
|
|
||||||
|
def report(text):
|
||||||
|
self.events.put(("status", text))
|
||||||
|
self.events.put(("log", text))
|
||||||
|
|
||||||
|
with open_stream(self.port, self.channel, self.prepare, report) as stream:
|
||||||
|
session = Session(strict_checksums=True)
|
||||||
|
|
||||||
|
def send(packet):
|
||||||
|
if stream.write(packet) != len(packet):
|
||||||
|
raise OSError("Incomplete Bluetooth write")
|
||||||
|
self.events.put(("log", "TX " + packet.hex(" ")))
|
||||||
|
|
||||||
|
report("Bluetooth connected; waiting for speaker handshake…")
|
||||||
|
send(session.start())
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
last_sent = time.monotonic()
|
||||||
|
while not self.stopped.is_set():
|
||||||
|
chunk = stream.read(1024)
|
||||||
|
if chunk:
|
||||||
|
self.events.put(("log", "RX " + chunk.hex(" ")))
|
||||||
|
update = session.receive(chunk)
|
||||||
|
self.publish(update)
|
||||||
|
for packet in update.outgoing:
|
||||||
|
send(packet)
|
||||||
|
last_sent = time.monotonic()
|
||||||
|
if not session.ready and time.monotonic() > deadline:
|
||||||
|
raise TimeoutError(
|
||||||
|
"No completed speaker handshake. Close the phone app and check the TX/RX log."
|
||||||
|
)
|
||||||
|
if session.ready and time.monotonic() - last_sent >= 0.1:
|
||||||
|
try:
|
||||||
|
payload = self.commands.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
send(session.command(payload))
|
||||||
|
last_sent = time.monotonic()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Translate Samsung payloads into activity text without claiming ACK = applied.
|
||||||
|
|
||||||
|
Outgoing settings and incoming reports have different field layouts. Unknown
|
||||||
|
commands stay explicitly unmapped; raw packet bytes remain available separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from controller.protocol import EQ, LIGHTS
|
||||||
|
|
||||||
|
SOURCES = {1: "Bluetooth", 2: "USB 1", 3: "USB 2", 4: "AUX 1", 5: "AUX 2"}
|
||||||
|
SOUND_TYPES = {
|
||||||
|
1: "volume",
|
||||||
|
2: "subwoofer volume",
|
||||||
|
3: "EQ preset",
|
||||||
|
4: "bass boost",
|
||||||
|
5: "DJ effect",
|
||||||
|
6: "tempo",
|
||||||
|
7: "mute",
|
||||||
|
}
|
||||||
|
REMOTE_KEYS = {
|
||||||
|
1: "Power",
|
||||||
|
2: "Cycle lighting",
|
||||||
|
7: "Cycle EQ preset",
|
||||||
|
15: "Volume up",
|
||||||
|
16: "Volume down",
|
||||||
|
17: "Play / pause",
|
||||||
|
18: "Next track",
|
||||||
|
19: "Previous track",
|
||||||
|
20: "Toggle mute",
|
||||||
|
21: "Toggle bass boost",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def name_for(mapping, value):
|
||||||
|
return next((name for name, code in mapping.items() if code == value), f"Unknown ({value})")
|
||||||
|
|
||||||
|
|
||||||
|
def describe_reply(data):
|
||||||
|
"""Read TYPE/VALUE/VALUE2 from reports, not the outgoing OPTION layout."""
|
||||||
|
if len(data) >= 4 and data[0] == 0x41:
|
||||||
|
kind, value = data[1:3]
|
||||||
|
if kind == 3:
|
||||||
|
return "EQ: " + name_for(EQ, value)
|
||||||
|
if kind == 1:
|
||||||
|
return f"Volume: {value}"
|
||||||
|
if kind == 2:
|
||||||
|
return f"Subwoofer volume: {value}"
|
||||||
|
if kind == 4:
|
||||||
|
return "Bass boost: " + {0: "On", 1: "Off"}.get(value, f"Unknown ({value})")
|
||||||
|
if kind == 7:
|
||||||
|
return "Mute: " + {1: "On", 2: "Off"}.get(value, f"Unknown ({value})")
|
||||||
|
if len(data) >= 2 and data[0] == 0x31:
|
||||||
|
return "Source: " + SOURCES.get(data[1], f"Unknown ({data[1]})")
|
||||||
|
if len(data) >= 3 and data[:2] == b"\x51\x03":
|
||||||
|
return "Lighting: " + name_for(LIGHTS, data[2])
|
||||||
|
if len(data) >= 2 and data[0] == 0x70:
|
||||||
|
# A remote-key echo is an action event, not an absolute playback state.
|
||||||
|
return "Button event: " + REMOTE_KEYS.get(data[1], f"Unknown key ({data[1]})")
|
||||||
|
if len(data) >= 7 and data[0] == 2 and len(data) >= 7 + data[5]:
|
||||||
|
sources = ", ".join(
|
||||||
|
SOURCES.get(value, f"Unknown ({value})") for value in data[6 : 6 + data[5]]
|
||||||
|
)
|
||||||
|
return "Speaker identified. Available sources: " + sources
|
||||||
|
return "Unmapped speaker reply: " + data.hex(" ")
|
||||||
|
|
||||||
|
|
||||||
|
def describe_command(data):
|
||||||
|
"""Describe the requested action; success must come from a later report."""
|
||||||
|
if data == b"\x01":
|
||||||
|
return "Request speaker connection"
|
||||||
|
if data == b"\x04":
|
||||||
|
return "Complete speaker handshake"
|
||||||
|
if data == b"\x32":
|
||||||
|
return "Read audio source"
|
||||||
|
if len(data) >= 2 and data[0] == 0x42:
|
||||||
|
return "Read " + SOUND_TYPES.get(data[1], f"sound setting {data[1]}")
|
||||||
|
if data == b"\x52\x03":
|
||||||
|
return "Read lighting mode"
|
||||||
|
if len(data) >= 2 and data[0] == 0x70:
|
||||||
|
return REMOTE_KEYS.get(data[1], f"Remote key {data[1]}")
|
||||||
|
if len(data) >= 2 and data[0] == 0x30:
|
||||||
|
return "Set source to " + SOURCES.get(data[1], f"Unknown ({data[1]})")
|
||||||
|
if len(data) >= 5 and data[0] == 0x40:
|
||||||
|
if data[1] == 3:
|
||||||
|
return "Set EQ to " + name_for(EQ, data[3])
|
||||||
|
if data[1] == 4:
|
||||||
|
return "Set bass boost to " + {0: "On", 1: "Off"}.get(data[3], f"Unknown ({data[3]})")
|
||||||
|
if len(data) >= 3 and data[:2] == b"\x50\x03":
|
||||||
|
return "Set lighting to " + name_for(LIGHTS, data[2])
|
||||||
|
if len(data) >= 5 and data[:2] == b"\x60\x02":
|
||||||
|
return f"Set lighting color to #{data[2]:02X}{data[3]:02X}{data[4]:02X}"
|
||||||
|
return "Unmapped command: " + data.hex(" ")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLog:
|
||||||
|
"""Bounded readable/debug history with ACKs matched to request sequences."""
|
||||||
|
|
||||||
|
def __init__(self, limit=300):
|
||||||
|
self.entries = deque(maxlen=limit)
|
||||||
|
self.pending = {}
|
||||||
|
|
||||||
|
def record(self, kind, value):
|
||||||
|
if kind == "frame":
|
||||||
|
if value.kind == 0:
|
||||||
|
request = self.pending.pop(value.sequence, f"request {value.sequence}")
|
||||||
|
self.entries.append(("Speaker acknowledged: " + request, None))
|
||||||
|
elif value.payload:
|
||||||
|
self.entries.append(("Speaker reports: " + describe_reply(value.payload), None))
|
||||||
|
return
|
||||||
|
if kind != "log":
|
||||||
|
return
|
||||||
|
if value.startswith(("TX BLE ", "TX ")):
|
||||||
|
raw = value.removeprefix("TX BLE ").removeprefix("TX ")
|
||||||
|
try:
|
||||||
|
packet = bytes.fromhex(raw)
|
||||||
|
if len(packet) >= 7:
|
||||||
|
description = describe_command(packet[6:-1])
|
||||||
|
self.pending[packet[3]] = description
|
||||||
|
self.entries.append(("Sending: " + description, value))
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if value.startswith(("RX BLE ", "RX ")) and not value.startswith("RX checksum"):
|
||||||
|
self.entries.append((None, value))
|
||||||
|
else:
|
||||||
|
self.entries.append((value, None))
|
||||||
|
|
||||||
|
def text(self, raw=False):
|
||||||
|
lines = []
|
||||||
|
for readable, packet in self.entries:
|
||||||
|
if readable:
|
||||||
|
lines.append(readable)
|
||||||
|
if raw and packet:
|
||||||
|
lines.append(" " + packet)
|
||||||
|
return "\n".join(lines) + ("\n" if lines else "")
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Read Windows' RFCOMM service discovery results without sending app commands.
|
||||||
|
|
||||||
|
Run separately from Tk so WinRT async operations use a clean event loop.
|
||||||
|
This diagnoses the real Windows Bluetooth stack, which WSL cannot reproduce.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
|
||||||
|
def check_channel(address, channel):
|
||||||
|
"""Test the app's channel-1 fallback, without writing protocol bytes.
|
||||||
|
|
||||||
|
A successful socket connection is evidence of an RFCOMM listener, not
|
||||||
|
evidence that the Samsung control handshake or commands are accepted.
|
||||||
|
"""
|
||||||
|
if not 1 <= channel <= 30:
|
||||||
|
raise ValueError("RFCOMM channel must be between 1 and 30")
|
||||||
|
if not hasattr(socket, "AF_BLUETOOTH") or not hasattr(socket, "BTPROTO_RFCOMM"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"This Python build lacks Bluetooth sockets. Use Windows CPython 3.14 or newer."
|
||||||
|
)
|
||||||
|
with socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) as stream:
|
||||||
|
stream.settimeout(10)
|
||||||
|
stream.connect((address, channel))
|
||||||
|
|
||||||
|
|
||||||
|
async def probe(name_filter, channel=None):
|
||||||
|
from winrt.windows.devices.bluetooth import BluetoothCacheMode, BluetoothDevice
|
||||||
|
from winrt.windows.devices.enumeration import DeviceInformation
|
||||||
|
|
||||||
|
selector = BluetoothDevice.get_device_selector_from_pairing_state(True)
|
||||||
|
# Python/WinRT exposes the AQS string overload under a separate name.
|
||||||
|
# find_all_async(selector) can instead try converting the string to a
|
||||||
|
# DeviceClass integer, unlike the similarly named C# API.
|
||||||
|
devices = await asyncio.wait_for(DeviceInformation.find_all_async_aqs_filter(selector), 20)
|
||||||
|
matches = [device for device in devices if name_filter.casefold() in device.name.casefold()]
|
||||||
|
if not matches:
|
||||||
|
print(f"No paired Bluetooth Classic device matching {name_filter!r}.")
|
||||||
|
print("Paired device names:", ", ".join(device.name for device in devices) or "(none)")
|
||||||
|
return 1
|
||||||
|
found_spp = False
|
||||||
|
channel_open = False
|
||||||
|
failures = False
|
||||||
|
for info in matches:
|
||||||
|
print(f"\nDevice: {info.name}")
|
||||||
|
device = None
|
||||||
|
try:
|
||||||
|
device = await asyncio.wait_for(BluetoothDevice.from_id_async(info.id), 20)
|
||||||
|
if device is None:
|
||||||
|
print("Windows could not open this device. Check Bluetooth access permissions.")
|
||||||
|
failures = True
|
||||||
|
continue
|
||||||
|
address_hex = f"{device.bluetooth_address:012X}"
|
||||||
|
address = ":".join(address_hex[i : i + 2] for i in range(0, 12, 2))
|
||||||
|
print("Bluetooth address:", address)
|
||||||
|
# Uncached discovery asks the speaker instead of relying on the
|
||||||
|
# cached service list used by some Windows settings screens.
|
||||||
|
# Keep the explicit overload for compatibility with older WinRT
|
||||||
|
# projections; recent versions retain it as a deprecated alias.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings(
|
||||||
|
"ignore",
|
||||||
|
message=r"BluetoothDevice\.get_rfcomm_services_with_cache_mode_async\(\).*",
|
||||||
|
category=DeprecationWarning,
|
||||||
|
)
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
device.get_rfcomm_services_with_cache_mode_async(BluetoothCacheMode.UNCACHED),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
print("Discovery status:", result.error)
|
||||||
|
if int(result.error) != 0:
|
||||||
|
failures = True
|
||||||
|
if not result.services:
|
||||||
|
print("No RFCOMM services returned. This is not proof the firmware lacks SPP.")
|
||||||
|
for service in result.services:
|
||||||
|
try:
|
||||||
|
service_uuid = str(service.service_id.uuid).lower()
|
||||||
|
is_spp = service_uuid == SPP_UUID
|
||||||
|
found_spp |= is_spp
|
||||||
|
print("Service:", service_uuid, "(SPP)" if is_spp else "")
|
||||||
|
print(
|
||||||
|
"Endpoint:",
|
||||||
|
service.connection_host_name.raw_name,
|
||||||
|
service.connection_service_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
if channel is not None:
|
||||||
|
print(f"Testing direct RFCOMM channel {channel} (no application data)…", flush=True)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(check_channel, address, channel)
|
||||||
|
channel_open = True
|
||||||
|
print("Direct channel connection: SUCCESS")
|
||||||
|
except Exception as exc:
|
||||||
|
failures = True
|
||||||
|
print(f"Direct channel connection: FAILED: {type(exc).__name__}: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Discovery failed: {type(exc).__name__}: {exc}")
|
||||||
|
failures = True
|
||||||
|
finally:
|
||||||
|
if device is not None:
|
||||||
|
device.close()
|
||||||
|
print("\nSPP service found:", found_spp)
|
||||||
|
print("No application commands were sent.")
|
||||||
|
return 0 if (found_spp or channel_open) and not failures else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if sys.platform != "win32":
|
||||||
|
print("Run this with Windows uv.exe in PowerShell, not Linux Python in WSL.")
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("name", nargs="?", default="MX-T40")
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel",
|
||||||
|
type=int,
|
||||||
|
choices=range(1, 31),
|
||||||
|
help="Test a direct RFCOMM channel without sending app commands",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return asyncio.run(probe(args.name, args.channel))
|
||||||
|
except ImportError as exc:
|
||||||
|
print("Missing Windows diagnostic dependency:", exc)
|
||||||
|
print(
|
||||||
|
"Use: uv.exe run --with-requirements requirements-diagnostics.txt -m controller.probe"
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Windows Bluetooth query failed: {type(exc).__name__}: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Samsung app packet framing, independent of Bluetooth and the desktop UI."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
EQ = dict(
|
||||||
|
zip(("Party", "Standard", "Hip-hop", "EDM", "Rock", "Latin", "House", "Reggae"), range(1, 9))
|
||||||
|
)
|
||||||
|
LIGHTS = dict(
|
||||||
|
zip(("Off", "Ambient", "Party", "Dance", "Thunder", "Star", "Lover", "Color"), range(8))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Frame:
|
||||||
|
kind: int
|
||||||
|
sequence: int
|
||||||
|
payload: bytes
|
||||||
|
checksum_valid: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Encoder:
|
||||||
|
"""Match CommandUtil, including its unusual signed-byte sequence rollover."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sequence = 0
|
||||||
|
|
||||||
|
def encode(self, payload):
|
||||||
|
payload = bytes(payload)
|
||||||
|
if not 1 <= len(payload) <= 65535:
|
||||||
|
raise ValueError("Payload must contain 1–65535 bytes")
|
||||||
|
self.sequence = self.sequence % 127 + 1
|
||||||
|
body = bytes((1, self.sequence)) + len(payload).to_bytes(2, "big") + payload
|
||||||
|
return b"\x00\xbb" + body + bytes((sum(body) & 255,))
|
||||||
|
|
||||||
|
|
||||||
|
class Decoder:
|
||||||
|
"""Buffer split/combined reads and optionally reject unknown checksums.
|
||||||
|
|
||||||
|
Hardware captures show both checksum variants: connection/source replies
|
||||||
|
exclude the type byte, while an EQ reply includes it. Accept both observed
|
||||||
|
sums plus the observed fixed-value lighting formula. The exact firmware
|
||||||
|
paths selecting these variants are not yet known. Compatibility mode
|
||||||
|
retains mismatches but marks Frame.checksum_valid false.
|
||||||
|
A conservative 4096-byte cap prevents bogus lengths from indefinitely
|
||||||
|
hiding subsequent control frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, strict_checksums=True):
|
||||||
|
self.buffer = bytearray()
|
||||||
|
self.strict_checksums = strict_checksums
|
||||||
|
|
||||||
|
def feed(self, chunk):
|
||||||
|
self.buffer.extend(chunk)
|
||||||
|
frames = []
|
||||||
|
while True:
|
||||||
|
start = self.buffer.find(b"\x00\xbb")
|
||||||
|
if start < 0:
|
||||||
|
self.buffer[:] = b"\x00" if self.buffer.endswith(b"\x00") else b""
|
||||||
|
break
|
||||||
|
del self.buffer[:start]
|
||||||
|
if len(self.buffer) < 7:
|
||||||
|
break
|
||||||
|
size = int.from_bytes(self.buffer[4:6], "big")
|
||||||
|
if self.buffer[2] not in (0, 1) or size > 4096:
|
||||||
|
del self.buffer[0]
|
||||||
|
continue
|
||||||
|
total = size + 7
|
||||||
|
if len(self.buffer) < total:
|
||||||
|
break
|
||||||
|
checksum = sum(self.buffer[3 : total - 1]) & 255
|
||||||
|
valid = self.buffer[total - 1] in (checksum, (checksum + self.buffer[2]) & 255)
|
||||||
|
# All captured 51/03 lighting reports (Ambient, Dance, Star, Lover)
|
||||||
|
# match a sum with the mode slot fixed at 1. This is an observed
|
||||||
|
# compatibility formula, not a claim about firmware implementation.
|
||||||
|
if (
|
||||||
|
self.buffer[2] == 1
|
||||||
|
and size == 3
|
||||||
|
and self.buffer[6:8] == b"\x51\x03"
|
||||||
|
and self.buffer[8] <= 7
|
||||||
|
):
|
||||||
|
lighting_checksum = (checksum - self.buffer[8] + 1) & 255
|
||||||
|
valid |= self.buffer[total - 1] == lighting_checksum
|
||||||
|
# Captured play/pause echoes sum as if the key slot were 1:
|
||||||
|
# sequence CD, payload 70/11 -> checksum 40; CE -> 41.
|
||||||
|
# Scope this rule to the observed payload bytes.
|
||||||
|
if self.buffer[2] == 1 and size == 2 and self.buffer[6:8] == b"\x70\x11":
|
||||||
|
valid |= self.buffer[total - 1] == (checksum - 0x11 + 1) & 255
|
||||||
|
if not valid and self.strict_checksums:
|
||||||
|
del self.buffer[0]
|
||||||
|
continue
|
||||||
|
frames.append(
|
||||||
|
Frame(self.buffer[2], self.buffer[3], bytes(self.buffer[6 : total - 1]), valid)
|
||||||
|
)
|
||||||
|
del self.buffer[:total]
|
||||||
|
return frames
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Samsung connection state shared by BLE and RFCOMM adapters.
|
||||||
|
|
||||||
|
The caller supplies received bytes and writes returned packets. This module
|
||||||
|
owns sequence numbers, framing, connection-info validation, and readiness;
|
||||||
|
it has no threads, Bluetooth dependencies, timers, or UI dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from controller.protocol import Decoder, Encoder, Frame
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Update:
|
||||||
|
frames: tuple[Frame, ...]
|
||||||
|
outgoing: tuple[bytes, ...]
|
||||||
|
became_ready: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""A fresh instance represents exactly one speaker connection."""
|
||||||
|
|
||||||
|
def __init__(self, strict_checksums=False):
|
||||||
|
self.encoder = Encoder()
|
||||||
|
self.decoder = Decoder(strict_checksums=strict_checksums)
|
||||||
|
self.ready = False
|
||||||
|
self._complete_sequence = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return self.command(b"\x01")
|
||||||
|
|
||||||
|
def command(self, payload):
|
||||||
|
return self.encoder.encode(payload)
|
||||||
|
|
||||||
|
def receive(self, chunk):
|
||||||
|
frames = tuple(self.decoder.feed(chunk))
|
||||||
|
outgoing = []
|
||||||
|
became_ready = False
|
||||||
|
for frame in frames:
|
||||||
|
# Speaker notifications have their own sequence counter. Only an
|
||||||
|
# ACK for our link-complete request establishes session readiness.
|
||||||
|
if frame.kind == 0 and frame.sequence == self._complete_sequence and not self.ready:
|
||||||
|
self.ready = True
|
||||||
|
became_ready = True
|
||||||
|
if (
|
||||||
|
frame.kind == 1
|
||||||
|
and frame.payload
|
||||||
|
and frame.payload[0] == 2
|
||||||
|
and self._complete_sequence is None
|
||||||
|
):
|
||||||
|
payload = frame.payload
|
||||||
|
if len(payload) < 7 or len(payload) < 7 + payload[5]:
|
||||||
|
raise ValueError("Malformed speaker connection response")
|
||||||
|
outgoing.append(self.command(b"\x04"))
|
||||||
|
self._complete_sequence = self.encoder.sequence
|
||||||
|
return Update(frames, tuple(outgoing), became_ready)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Byte-stream adapters for direct RFCOMM and optional serial COM ports."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
|
|
||||||
|
class RfcommStream:
|
||||||
|
"""Adapt an RFCOMM socket to the worker's serial-like read/write API.
|
||||||
|
|
||||||
|
Idle reads return empty bytes, as pyserial does; a socket EOF instead
|
||||||
|
raises, so a lost speaker connection cannot look like endless idle time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, address, channel=1):
|
||||||
|
self.address = address
|
||||||
|
self.channel = channel
|
||||||
|
self.socket = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if not 1 <= self.channel <= 30:
|
||||||
|
raise ValueError("RFCOMM channel must be between 1 and 30")
|
||||||
|
if not hasattr(socket, "AF_BLUETOOTH") or not hasattr(socket, "BTPROTO_RFCOMM"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Direct Bluetooth requires a Python build with RFCOMM support. Use Windows Python 3.14."
|
||||||
|
)
|
||||||
|
self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
|
||||||
|
try:
|
||||||
|
self.socket.settimeout(10)
|
||||||
|
self.socket.connect((self.address, self.channel))
|
||||||
|
except TimeoutError as exc:
|
||||||
|
self.socket.close()
|
||||||
|
self.socket = None
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Bluetooth connection timed out before the Samsung handshake "
|
||||||
|
f"({self.address}, channel {self.channel})."
|
||||||
|
) from exc
|
||||||
|
except BaseException:
|
||||||
|
self.socket.close()
|
||||||
|
self.socket = None
|
||||||
|
raise
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
if self.socket is not None:
|
||||||
|
self.socket.close()
|
||||||
|
self.socket = None
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.socket.settimeout(2)
|
||||||
|
# sendall handles partial socket writes. Never retry a whole command
|
||||||
|
# after a send failure: remote key actions could otherwise repeat.
|
||||||
|
try:
|
||||||
|
self.socket.sendall(data)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise TimeoutError(
|
||||||
|
"Bluetooth connected, but sending a Samsung packet timed out."
|
||||||
|
) from exc
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
self.socket.settimeout(0.05)
|
||||||
|
try:
|
||||||
|
data = self.socket.recv(size)
|
||||||
|
except TimeoutError:
|
||||||
|
return b""
|
||||||
|
if not data:
|
||||||
|
raise ConnectionError("The speaker closed the Bluetooth connection.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class PreparedRfcommStream(RfcommStream):
|
||||||
|
"""Keep the WinRT device alive across the socket session, as the probe did."""
|
||||||
|
|
||||||
|
def __init__(self, address, channel, report):
|
||||||
|
super().__init__(address, channel)
|
||||||
|
self.report = report
|
||||||
|
self.device = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
from controller.windows_bluetooth import prepare_device
|
||||||
|
|
||||||
|
self.device = asyncio.run(prepare_device(self.address, self.report))
|
||||||
|
try:
|
||||||
|
self.report("Opening direct RFCOMM socket with Windows device handle retained…")
|
||||||
|
return super().__enter__()
|
||||||
|
except BaseException:
|
||||||
|
self.device.close()
|
||||||
|
self.device = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
try:
|
||||||
|
super().__exit__(*args)
|
||||||
|
finally:
|
||||||
|
if self.device is not None:
|
||||||
|
self.device.close()
|
||||||
|
self.device = None
|
||||||
|
|
||||||
|
|
||||||
|
def open_stream(target, channel=1, prepare=False, report=lambda text: None):
|
||||||
|
"""A Bluetooth MAC selects direct RFCOMM; other targets select serial."""
|
||||||
|
if re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", target):
|
||||||
|
if prepare:
|
||||||
|
return PreparedRfcommStream(target, channel, report)
|
||||||
|
return RfcommStream(target, channel)
|
||||||
|
import serial
|
||||||
|
|
||||||
|
return serial.Serial(target, 115200, timeout=0.05, write_timeout=2)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Optional WinRT initialization matching the successful service probe."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_device(address, report):
|
||||||
|
"""Return a live Windows device handle after uncached service discovery.
|
||||||
|
|
||||||
|
Keeping this handle alive is an experiment based on the difference between
|
||||||
|
the successful probe and a failing bare socket connect. It is not evidence
|
||||||
|
that every RFCOMM device requires this initialization.
|
||||||
|
"""
|
||||||
|
from winrt.windows.devices.bluetooth import BluetoothCacheMode, BluetoothDevice
|
||||||
|
|
||||||
|
report("Opening speaker through Windows Bluetooth API…")
|
||||||
|
device = await asyncio.wait_for(
|
||||||
|
BluetoothDevice.from_bluetooth_address_async(int(address.replace(":", ""), 16)), 20
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
raise ConnectionError("Windows Bluetooth API could not open the speaker.")
|
||||||
|
try:
|
||||||
|
report("Querying speaker services before direct connection…")
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings(
|
||||||
|
"ignore",
|
||||||
|
message=r"BluetoothDevice\.get_rfcomm_services_with_cache_mode_async\(\).*",
|
||||||
|
category=DeprecationWarning,
|
||||||
|
)
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
device.get_rfcomm_services_with_cache_mode_async(BluetoothCacheMode.UNCACHED), 30
|
||||||
|
)
|
||||||
|
report(
|
||||||
|
f"Windows service query: status {result.error}, {len(result.services)} RFCOMM services"
|
||||||
|
)
|
||||||
|
for service in result.services:
|
||||||
|
service.close()
|
||||||
|
# Empty SDP results were also observed in the successful direct probe;
|
||||||
|
# they must not prevent the explicit channel-1 connection attempt.
|
||||||
|
return device
|
||||||
|
except BaseException:
|
||||||
|
device.close()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Thread/event-queue interface shared by connection adapters.
|
||||||
|
|
||||||
|
The GUI enqueues payload bytes in commands and consumes events. Only a worker
|
||||||
|
touches its transport; workers never call Tk. Settings are never retried
|
||||||
|
automatically because repeating a remote key could perform an action twice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from controller.commands import initial_queries
|
||||||
|
from controller.session import Update
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionWorker(threading.Thread):
|
||||||
|
def __init__(self, target, events):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.port = target
|
||||||
|
self.events = events
|
||||||
|
self.commands = queue.Queue(maxsize=32)
|
||||||
|
self.stopped = threading.Event()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.work()
|
||||||
|
except Exception as exc:
|
||||||
|
self.events.put(("log", f"ERROR {type(exc).__name__}: {exc}"))
|
||||||
|
self.events.put(("error", str(exc)))
|
||||||
|
finally:
|
||||||
|
self.events.put(("closed", None))
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stopped.set()
|
||||||
|
|
||||||
|
def publish(self, update: Update):
|
||||||
|
"""Deliver replies and queue initial queries once per handshake."""
|
||||||
|
for frame in update.frames:
|
||||||
|
self.events.put(("frame", frame))
|
||||||
|
if not frame.checksum_valid:
|
||||||
|
self.events.put(
|
||||||
|
(
|
||||||
|
"log",
|
||||||
|
"RX checksum differs from known rules; retained payload "
|
||||||
|
+ frame.payload.hex(" "),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if frame.kind and frame.payload:
|
||||||
|
self.events.put(("payload", frame.payload))
|
||||||
|
if update.became_ready:
|
||||||
|
self.events.put(("ready", None))
|
||||||
|
for payload in initial_queries():
|
||||||
|
self.commands.put_nowait(payload)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
The protocol module has no Bluetooth or GUI dependencies. Tk exists only in
|
||||||
|
`controller/gui.py`, which `cli.py` imports after parsing arguments. Importing
|
||||||
|
the package or running `--help` does not load Tk or Bleak.
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `protocol.py` | Packet encoding, stream framing, observed checksum variants |
|
||||||
|
| `commands.py` | Semantic command builders; no packet layout in Tk callbacks |
|
||||||
|
| `session.py` | Handshake, outgoing sequence numbers, connection readiness |
|
||||||
|
| `messages.py` | English command/reply descriptions and bounded activity history |
|
||||||
|
| `worker.py` | Thread lifecycle and event/command queues |
|
||||||
|
| `ble_link.py` | Bleak connection, notifications, characteristic writes |
|
||||||
|
| `link.py`, `transport.py` | Experimental RFCOMM and serial adapters |
|
||||||
|
| `gui.py` | Tk controls and event rendering |
|
||||||
|
| `cli.py`, `__main__.py` | Installed command and backwards-compatible module entry |
|
||||||
|
| `ble_probe.py`, `probe.py` | Endpoint diagnostics |
|
||||||
|
| `windows_bluetooth.py` | Experimental RFCOMM initialization |
|
||||||
|
|
||||||
|
## Session interface
|
||||||
|
|
||||||
|
Create one `Session` per connection. Write `session.start()` to begin. Pass
|
||||||
|
received chunks to `session.receive(chunk)` and write every returned `outgoing`
|
||||||
|
packet. `Update.frames` carries decoded packets; `became_ready` is emitted once
|
||||||
|
when the speaker acknowledges the link-complete request. Use
|
||||||
|
`session.command(payload)` for subsequent outgoing packets.
|
||||||
|
|
||||||
|
Both transports cross this same seam. The session owns framing and the state
|
||||||
|
machine, so fixes do not need to be repeated in separate Bluetooth loops.
|
||||||
|
|
||||||
|
A transport connection, a GATT write response, a Samsung packet acknowledgment,
|
||||||
|
and a speaker state report are distinct events. Only the link-complete ACK
|
||||||
|
makes the session ready. Speaker notifications have an independent sequence
|
||||||
|
counter and cannot substitute for that ACK.
|
||||||
|
|
||||||
|
## Threads and ownership
|
||||||
|
|
||||||
|
Tk runs on the main thread. Each connection worker owns its transport. BLE's
|
||||||
|
async event loop lives entirely within the worker thread. The GUI submits
|
||||||
|
payloads through a bounded command queue and consumes events through another
|
||||||
|
queue; workers never call Tk methods.
|
||||||
|
|
||||||
|
BLE notifications enter a bounded async queue and can contain partial or
|
||||||
|
combined packets. The decoder retains framing state between notifications.
|
||||||
|
Queue overflow terminates the connection rather than silently losing framing.
|
||||||
|
|
||||||
|
Writes are spaced and never automatically retried. Retrying a volume or mute
|
||||||
|
key could perform the action twice. The GUI enables controls only after the
|
||||||
|
protocol handshake and disables them on connection closure.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Protocol tests use captured byte fixtures independently of the outgoing
|
||||||
|
encoder. Session tests cover split replies, duplicate replies, and ACK matching.
|
||||||
|
Transport tests verify subscription order, write mode, disconnects, resource
|
||||||
|
cleanup, and readable error phases. Presentation tests verify the difference
|
||||||
|
between acknowledgment and a reported state. No test opens physical Bluetooth.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Implemented and potential features
|
||||||
|
|
||||||
|
“Verified” below means the owner exercised the control on a physical MX-T40,
|
||||||
|
with packet reports or direct observation. It does not establish compatibility
|
||||||
|
with other Samsung models.
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| BLE connection/status queries | Verified |
|
||||||
|
| EQ presets | Verified, including Rock/Hip-hop/Standard transitions |
|
||||||
|
| Bass boost, volume, mute | Verified |
|
||||||
|
| Lighting presets and RGB color | Verified; RGB activates COLOR mode |
|
||||||
|
| Play/pause and source selection | Owner reports working; playback echo is an event, not absolute state |
|
||||||
|
| DJ effects: off, delay, filter, flanger, chorus, wah-wah | Found in Android app; not implemented or tested here |
|
||||||
|
| Tempo controls | Found in Android app; not implemented or tested here |
|
||||||
|
| USB playback, repeat/shuffle, browsing and track/time information | Found in Android app; not implemented or tested here |
|
||||||
|
| Speaker grouping / wireless party controls | Found in Android app; not implemented or tested here |
|
||||||
|
| Bluetooth multi-connect setting/status | Found in Android app; model compatibility unverified |
|
||||||
|
| Arbitrary per-band EQ | No Samsung app command established |
|
||||||
|
|
||||||
|
## Next work
|
||||||
|
|
||||||
|
Keep the working transport stable. Add read-only status decoding before new
|
||||||
|
controls, then validate one feature at a time on hardware. DJ effects are a
|
||||||
|
useful next feature because the app contains both effect selection and level
|
||||||
|
commands. Grouping is more involved because it changes relationships between
|
||||||
|
speakers and needs more than one device for testing.
|
||||||
|
|
||||||
|
The firmware XML is a Qualcomm configuration schema, not a dump of configured
|
||||||
|
values or a Samsung command dictionary. It documents audio routing/gain curves,
|
||||||
|
button/LED patterns, codec-related definitions, power policies, and GAIA fields.
|
||||||
|
Listed schema options do not prove those features are enabled on the MX-T40.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Samsung control protocol notes
|
||||||
|
|
||||||
|
These original interoperability notes describe observations from an MX-T40 and
|
||||||
|
analysis of the Android Sound Tower app version 0.0.80. Vendor application code
|
||||||
|
and firmware are not included. The working desktop transport is BLE; the iOS
|
||||||
|
app's internal transport/framework has not been established.
|
||||||
|
|
||||||
|
## BLE endpoints
|
||||||
|
|
||||||
|
| Role | UUID |
|
||||||
|
| --- | --- |
|
||||||
|
| Samsung service | `0000ffa0-0000-1000-8000-00805f9b34fb` |
|
||||||
|
| Write, with response | `0000ffa1-0000-1000-8000-00805f9b34fb` |
|
||||||
|
| Notify | `0000ffa2-0000-1000-8000-00805f9b34fb` |
|
||||||
|
|
||||||
|
Subscribe before sending the connection request. Commands currently exposed by
|
||||||
|
the GUI fit within 20 bytes; no larger-command fragmentation scheme is claimed.
|
||||||
|
The speaker also exposes a separate Qualcomm service with UUID
|
||||||
|
`00001100-d102-11e1-9b23-00025b00a5a5`, which this controller does not use.
|
||||||
|
|
||||||
|
MX-T40 model advertisements include FE40/FF40; the Android discovery code also
|
||||||
|
maps FE41/FF41 to T40 names. An advertisement UUID identifies discovery, not
|
||||||
|
necessarily a writable control service.
|
||||||
|
|
||||||
|
## Framing
|
||||||
|
|
||||||
|
```text
|
||||||
|
00 BB TYPE SEQUENCE LENGTH_HI LENGTH_LO PAYLOAD... CHECKSUM
|
||||||
|
```
|
||||||
|
|
||||||
|
Type 0 is an acknowledgment; type 1 carries data. Length counts payload bytes
|
||||||
|
only, in big-endian order. Outgoing sequence numbers cycle 1–127, matching the
|
||||||
|
Android app's signed-byte rollover. Incoming notifications use a separate
|
||||||
|
sequence counter, observed above 127 as well.
|
||||||
|
|
||||||
|
Outgoing checksums sum type, sequence, both length bytes, and payload, modulo
|
||||||
|
256. Incoming packets have several observed checksum variants:
|
||||||
|
|
||||||
|
- Connection/source reports: sum excluding type.
|
||||||
|
- Sound reports: sum including type.
|
||||||
|
- Lighting reports `51 03 MODE`: captured packets match a checksum calculated
|
||||||
|
as though MODE were 1, across Ambient/Dance/Star/Lover/Off observations.
|
||||||
|
- Play/pause echoes `70 11`: the two captured replies similarly match a key
|
||||||
|
slot of 1. This compatibility rule is scoped to that specific payload.
|
||||||
|
|
||||||
|
Why the firmware emits these variants is unknown. Strict decoding recognizes
|
||||||
|
these observed rules. BLE uses advisory mode, retaining structurally framed
|
||||||
|
packets with unfamiliar checksums and flagging them in the activity log. This
|
||||||
|
is compatibility behavior, not a cryptographic integrity guarantee.
|
||||||
|
|
||||||
|
## Handshake
|
||||||
|
|
||||||
|
1. Send payload `01`.
|
||||||
|
2. Receive ACK and connection information beginning `02`.
|
||||||
|
3. Send payload `04` (link complete).
|
||||||
|
4. Receive the ACK matching that outgoing sequence; only then enable controls.
|
||||||
|
5. Query source (`32`) and EQ (`42 03`).
|
||||||
|
|
||||||
|
The captured connection info contains model byte `34` and source IDs
|
||||||
|
`01 02 04 05`: Bluetooth, USB 1, AUX 1, AUX 2. The two bytes following the initial
|
||||||
|
`02` are interpreted by the Android app as protocol version.
|
||||||
|
|
||||||
|
## Payloads
|
||||||
|
|
||||||
|
Numbers below are hexadecimal unless explicitly called decimal.
|
||||||
|
|
||||||
|
| Action | Payload |
|
||||||
|
| --- | --- |
|
||||||
|
| Remote key | `70 KEY` |
|
||||||
|
| Select source | `30 SOURCE` |
|
||||||
|
| Query source | `32` |
|
||||||
|
| Set sound option | `40 TYPE OPTION VALUE EXTRA` |
|
||||||
|
| Query sound option | `42 TYPE` |
|
||||||
|
| Set system option | `50 TYPE VALUE` |
|
||||||
|
| Query system option | `52 TYPE` |
|
||||||
|
| Set RGB color | `60 02 RED GREEN BLUE` |
|
||||||
|
|
||||||
|
Sound TYPE: volume=1, subwoofer=2, EQ=3, bass=4, DJ=5, tempo=6, mute=7.
|
||||||
|
For the implemented EQ/bass controls OPTION and EXTRA are zero.
|
||||||
|
Sound replies use `41 TYPE VALUE VALUE2`, a different layout from writes.
|
||||||
|
|
||||||
|
EQ values (decimal): Party=1, Standard=2, Hip-hop=3, EDM=4, Rock=5, Latin=6,
|
||||||
|
House=7, Reggae=8. Bass values: on=0, off=1. Mute reports: on=1, off=2.
|
||||||
|
|
||||||
|
System TYPE=3 controls lighting. Mode values (decimal): Off=0, Ambient=1,
|
||||||
|
Party=2, Dance=3, Thunder=4, Star=5, Lover=6, Color=7. Reply: `51 03 MODE`.
|
||||||
|
The speaker displays COLOR when the owner chooses an RGB color. The desktop
|
||||||
|
queries lighting mode after RGB writes rather than treating ACK as mode proof.
|
||||||
|
|
||||||
|
Remote keys: volume up=`0F`, volume down=`10`, play/pause=`11`, mute=`14`.
|
||||||
|
A reply `70 11` is a button-event echo, not an absolute playing/paused state.
|
||||||
|
|
||||||
|
## Additional app commands, untested here
|
||||||
|
|
||||||
|
The app defines grouping `05 TYPE CONTROL`, USB playback/browsing/status and
|
||||||
|
repeat controls, DJ selection/levels, tempo, and Bluetooth multi-connect state.
|
||||||
|
See FEATURES.md for the distinction between discovered and verified behavior.
|
||||||
|
Do not infer arbitrary EQ-band access from the firmware configuration schema.
|
||||||
|
|
||||||
|
## Evidence and limits
|
||||||
|
|
||||||
|
Tests contain small, independently captured packet fixtures: connection info,
|
||||||
|
ACKs, source/EQ replies, lighting checksum variants, and play/pause echoes. Real
|
||||||
|
Bluetooth addresses are replaced with synthetic ones. Physical verification
|
||||||
|
covers one MX-T40 on Windows; other models, firmware versions, and platforms
|
||||||
|
remain unverified. RFCOMM channel 1 worked initially but later failed repeatedly;
|
||||||
|
no general explanation or guaranteed workaround has been established.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=77"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "soundtower-controller"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Unofficial Samsung Sound Tower BLE desktop controller"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = ["bleak>=3,<4", "pyserial>=3.5,<4"]
|
||||||
|
keywords = ["bluetooth", "ble", "speaker", "mx-t40"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Operating System :: Microsoft :: Windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
diagnostics = [
|
||||||
|
"winrt-Windows.Devices.Bluetooth; sys_platform == 'win32'",
|
||||||
|
"winrt-Windows.Devices.Bluetooth.Rfcomm; sys_platform == 'win32'",
|
||||||
|
"winrt-Windows.Devices.Enumeration; sys_platform == 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["ruff>=0.15,<0.16", "build>=1.2,<2"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
soundtower = "controller.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["controller"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "I", "UP"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
winrt-Windows.Devices.Bluetooth; sys_platform == 'win32'
|
||||||
|
winrt-Windows.Devices.Bluetooth.Rfcomm; sys_platform == 'win32'
|
||||||
|
winrt-Windows.Devices.Enumeration; sys_platform == 'win32'
|
||||||
|
-r requirements.txt
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pyserial>=3.5,<4
|
||||||
|
bleak>=3,<4
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Copy an explicit source allowlist into a new repository directory.
|
||||||
|
|
||||||
|
This workspace also holds firmware research. Exporting from an allowlist,
|
||||||
|
rather than recursively copying the workspace and subtracting known inputs,
|
||||||
|
prevents newly added vendor artifacts from silently entering a release.
|
||||||
|
The destination must be empty; existing content is never replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT_FILES = (
|
||||||
|
"README.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"LICENSE",
|
||||||
|
"LICENSE.txt",
|
||||||
|
"pyproject.toml",
|
||||||
|
"uv.lock",
|
||||||
|
"MANIFEST.in",
|
||||||
|
"requirements.txt",
|
||||||
|
"requirements-diagnostics.txt",
|
||||||
|
".gitignore",
|
||||||
|
)
|
||||||
|
TREES = {
|
||||||
|
"controller": {".py"},
|
||||||
|
"tests": {".py"},
|
||||||
|
"docs": {".md"},
|
||||||
|
"scripts": {".py"},
|
||||||
|
".github": {".yml", ".yaml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def source_files(root):
|
||||||
|
root = Path(root).resolve()
|
||||||
|
selected = [root / name for name in ROOT_FILES if (root / name).is_file()]
|
||||||
|
for folder, suffixes in TREES.items():
|
||||||
|
selected.extend(
|
||||||
|
path
|
||||||
|
for path in (root / folder).rglob("*")
|
||||||
|
if path.is_file() and path.suffix in suffixes and "__pycache__" not in path.parts
|
||||||
|
)
|
||||||
|
for path in selected:
|
||||||
|
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||||
|
raise ValueError(
|
||||||
|
f"Export refuses symlinked or external source: {path.relative_to(root)}"
|
||||||
|
)
|
||||||
|
return sorted(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def export_project(root, destination):
|
||||||
|
root = Path(root).resolve()
|
||||||
|
destination = Path(destination)
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise ValueError("Export destination cannot be a symlink")
|
||||||
|
destination = destination.resolve()
|
||||||
|
if destination == root or any(destination.is_relative_to(root / folder) for folder in TREES):
|
||||||
|
raise ValueError("Choose a destination outside the included source folders")
|
||||||
|
if destination.exists() and (not destination.is_dir() or any(destination.iterdir())):
|
||||||
|
raise ValueError("Export destination must be a new or empty directory")
|
||||||
|
files = source_files(root)
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
for source in files:
|
||||||
|
target = destination / source.relative_to(root)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
return len(files)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("destination", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
try:
|
||||||
|
count = export_project(root, args.destination)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
print(f"Exported {count} source/documentation files to {args.destination}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Replay the captured reply through asynchronous FFA1/FFA2 operations."""
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from controller.ble_link import NOTIFY, SERVICE, WRITE, BLELink
|
||||||
|
|
||||||
|
|
||||||
|
class BLELinkTests(unittest.TestCase):
|
||||||
|
def run_peer(self, write_error=False):
|
||||||
|
operations = []
|
||||||
|
closed = []
|
||||||
|
write = types.SimpleNamespace(uuid=WRITE, properties=["write"])
|
||||||
|
notify = types.SimpleNamespace(uuid=NOTIFY, properties=["notify"])
|
||||||
|
service = types.SimpleNamespace(
|
||||||
|
get_characteristic=lambda uuid: {WRITE: write, NOTIFY: notify}.get(uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Events(queue.Queue):
|
||||||
|
def put(self, event, *args, **kwargs):
|
||||||
|
super().put(event, *args, **kwargs)
|
||||||
|
if event[0] == "ready":
|
||||||
|
link.stop()
|
||||||
|
|
||||||
|
events = Events()
|
||||||
|
link = BLELink("12:34:56:78:9A:BC", events)
|
||||||
|
|
||||||
|
class Peer:
|
||||||
|
services = types.SimpleNamespace(
|
||||||
|
get_service=lambda uuid: service if uuid == SERVICE else None
|
||||||
|
)
|
||||||
|
is_connected = True
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
async def start_notify(self, characteristic, callback):
|
||||||
|
operations.append(("subscribe", characteristic.uuid))
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
async def write_gatt_char(self, characteristic, data, response):
|
||||||
|
operations.append(("write", characteristic.uuid, data, response))
|
||||||
|
if write_error:
|
||||||
|
raise OSError("GATT write failed")
|
||||||
|
if data[6:-1] == b"\x01":
|
||||||
|
self.callback(notify, bytes.fromhex("00 bb 00 01 00 00 01"))
|
||||||
|
reply = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
|
||||||
|
self.callback(notify, reply[:8])
|
||||||
|
self.callback(notify, reply[8:])
|
||||||
|
elif data[6:-1] == b"\x04":
|
||||||
|
self.callback(notify, bytes.fromhex("00 bb 00 02 00 00 02"))
|
||||||
|
|
||||||
|
scanner = types.SimpleNamespace(find_device_by_address=AsyncMock(return_value=object()))
|
||||||
|
with patch.dict(
|
||||||
|
sys.modules,
|
||||||
|
{
|
||||||
|
"bleak": types.SimpleNamespace(
|
||||||
|
BleakScanner=scanner, BleakClient=lambda *a, **k: Peer()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
):
|
||||||
|
link.run()
|
||||||
|
return operations, list(events.queue), closed
|
||||||
|
|
||||||
|
def test_subscribe_before_handshake_and_reassemble_notifications(self):
|
||||||
|
operations, events, closed = self.run_peer()
|
||||||
|
self.assertEqual(operations[0], ("subscribe", NOTIFY))
|
||||||
|
writes = operations[1:]
|
||||||
|
self.assertEqual([op[1] for op in writes], [WRITE, WRITE])
|
||||||
|
self.assertEqual(
|
||||||
|
[op[2] for op in writes],
|
||||||
|
[
|
||||||
|
bytes.fromhex("00 bb 01 01 00 01 01 04"),
|
||||||
|
bytes.fromhex("00 bb 01 02 00 01 04 08"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(op[3] is True for op in writes))
|
||||||
|
self.assertEqual(sum(kind == "ready" for kind, _ in events), 1)
|
||||||
|
self.assertFalse(any(kind == "error" for kind, _ in events))
|
||||||
|
self.assertEqual(closed, [True])
|
||||||
|
|
||||||
|
def test_write_error_disconnects_without_enabling_controls(self):
|
||||||
|
_, events, closed = self.run_peer(write_error=True)
|
||||||
|
self.assertFalse(any(kind == "ready" for kind, _ in events))
|
||||||
|
self.assertTrue(
|
||||||
|
any(kind == "error" and "GATT write failed" in value for kind, value in events)
|
||||||
|
)
|
||||||
|
self.assertEqual(events[-1], ("closed", None))
|
||||||
|
self.assertEqual(closed, [True])
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Match semantic command builders against independently captured writes."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller import commands
|
||||||
|
from controller.protocol import Encoder
|
||||||
|
|
||||||
|
|
||||||
|
class CommandTests(unittest.TestCase):
|
||||||
|
def test_rock_packet_matches_hardware_capture(self):
|
||||||
|
encoder = Encoder()
|
||||||
|
encoder.sequence = 11
|
||||||
|
self.assertEqual(
|
||||||
|
encoder.encode(commands.set_eq(5)), bytes.fromhex("00 bb 01 0c 00 05 40 03 00 05 00 5a")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bass_off_matches_hardware_capture(self):
|
||||||
|
encoder = Encoder()
|
||||||
|
encoder.sequence = 6
|
||||||
|
self.assertEqual(
|
||||||
|
encoder.encode(commands.set_bass(False)),
|
||||||
|
bytes.fromhex("00 bb 01 07 00 05 40 04 00 01 00 52"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_rgb_is_rejected_before_transport(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
commands.set_rgb(256, 0, 0)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
commands.set_rgb(0.5, 0, 0)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Guard the seam between private firmware research and publishable source."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scripts.export_project import export_project
|
||||||
|
|
||||||
|
|
||||||
|
class ExportTests(unittest.TestCase):
|
||||||
|
def test_excludes_vendor_inputs_and_preserves_source(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary) / "workspace"
|
||||||
|
for name in (
|
||||||
|
"controller/main.py",
|
||||||
|
"controller/cache.bin",
|
||||||
|
"README.md",
|
||||||
|
"analysis/app-source/Vendor.java",
|
||||||
|
"speaker.bin",
|
||||||
|
"app.apk",
|
||||||
|
".env",
|
||||||
|
):
|
||||||
|
path = root / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("fixture")
|
||||||
|
output = Path(temporary) / "export"
|
||||||
|
export_project(root, output)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(
|
||||||
|
str(path.relative_to(output)).replace("\\", "/")
|
||||||
|
for path in output.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
),
|
||||||
|
["README.md", "controller/main.py"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refuses_existing_content(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
output = root / "export"
|
||||||
|
output.mkdir()
|
||||||
|
marker = output / "keep.txt"
|
||||||
|
marker.write_text("do not replace")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
export_project(root, output)
|
||||||
|
self.assertEqual(marker.read_text(), "do not replace")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""The protocol and CLI must remain usable on machines without a display."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTests(unittest.TestCase):
|
||||||
|
def test_headless_import_and_help(self):
|
||||||
|
script = """
|
||||||
|
import sys
|
||||||
|
from controller import protocol, session, messages, cli
|
||||||
|
assert 'tkinter' not in sys.modules
|
||||||
|
assert 'bleak' not in sys.modules
|
||||||
|
try:
|
||||||
|
cli.main(['--help'])
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert exc.code == 0
|
||||||
|
assert 'tkinter' not in sys.modules
|
||||||
|
"""
|
||||||
|
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("--transport", result.stdout)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Exercise the connection state machine with a scripted serial peer."""
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from controller.link import Link
|
||||||
|
|
||||||
|
|
||||||
|
class HandshakeTests(unittest.TestCase):
|
||||||
|
def run_peer(self, malformed=False):
|
||||||
|
events = queue.Queue()
|
||||||
|
link = Link("COM7", events)
|
||||||
|
packets = []
|
||||||
|
# Replay actual device ACK + CONNECT_INFO, then a link-complete ACK.
|
||||||
|
# Peer data sequence 0x1d is independent of our outbound sequence 1.
|
||||||
|
info = (
|
||||||
|
bytes.fromhex("00 bb 01 1d 00 01 02 20")
|
||||||
|
if malformed
|
||||||
|
else bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
|
||||||
|
)
|
||||||
|
reads = iter(
|
||||||
|
(bytes.fromhex("00 bb 00 01 00 00 01"), info, bytes.fromhex("00 bb 00 02 00 00 02"))
|
||||||
|
)
|
||||||
|
|
||||||
|
class Peer:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
packets.append(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
try:
|
||||||
|
return next(reads)
|
||||||
|
except StopIteration:
|
||||||
|
link.stop()
|
||||||
|
return b""
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
sys.modules, {"serial": types.SimpleNamespace(Serial=lambda *a, **k: Peer())}
|
||||||
|
):
|
||||||
|
link.run()
|
||||||
|
return packets, list(events.queue)
|
||||||
|
|
||||||
|
def test_waits_for_peer_before_ready(self):
|
||||||
|
packets, events = self.run_peer()
|
||||||
|
self.assertEqual([p[6:-1] for p in packets], [b"\x01", b"\x04"])
|
||||||
|
self.assertEqual(packets[1], bytes.fromhex("00 bb 01 02 00 01 04 08"))
|
||||||
|
self.assertEqual(sum(kind == "ready" for kind, _ in events), 1)
|
||||||
|
self.assertFalse(any(kind == "error" for kind, _ in events))
|
||||||
|
|
||||||
|
def test_malformed_info_never_enables_controls(self):
|
||||||
|
packets, events = self.run_peer(malformed=True)
|
||||||
|
self.assertEqual(len(packets), 1)
|
||||||
|
self.assertFalse(any(kind == "ready" for kind, _ in events))
|
||||||
|
self.assertTrue(any(kind == "error" for kind, _ in events))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Human-readable packet meaning, including ACK versus actual state reports."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller.messages import ActivityLog, describe_command, describe_reply
|
||||||
|
from controller.protocol import Frame
|
||||||
|
|
||||||
|
|
||||||
|
class MessageTests(unittest.TestCase):
|
||||||
|
def test_outgoing_layout_and_rgb(self):
|
||||||
|
self.assertEqual(describe_command(bytes.fromhex("40 03 00 05 00")), "Set EQ to Rock")
|
||||||
|
self.assertEqual(
|
||||||
|
describe_command(bytes.fromhex("60 02 ff 80 00")), "Set lighting color to #FF8000"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_color_is_a_distinct_mode_and_play_pause_is_an_event(self):
|
||||||
|
self.assertEqual(describe_command(bytes.fromhex("50 03 07")), "Set lighting to Color")
|
||||||
|
self.assertEqual(describe_reply(bytes.fromhex("51 03 07")), "Lighting: Color")
|
||||||
|
self.assertEqual(describe_reply(bytes.fromhex("70 11")), "Button event: Play / pause")
|
||||||
|
|
||||||
|
def test_capture_maps_to_english_with_optional_hex(self):
|
||||||
|
log = ActivityLog()
|
||||||
|
raw = "TX BLE 00 bb 01 0c 00 05 40 03 00 05 00 5a"
|
||||||
|
log.record("log", raw)
|
||||||
|
log.record("log", "RX BLE 00 bb 00 0c 00 00 0c")
|
||||||
|
log.record("frame", Frame(0, 12, b""))
|
||||||
|
log.record("frame", Frame(1, 0xAD, bytes.fromhex("41 03 05 00")))
|
||||||
|
text = log.text()
|
||||||
|
self.assertIn("Sending: Set EQ to Rock", text)
|
||||||
|
self.assertIn("Speaker acknowledged: Set EQ to Rock", text)
|
||||||
|
self.assertIn("Speaker reports: EQ: Rock", text)
|
||||||
|
self.assertNotIn("00 bb", text)
|
||||||
|
self.assertIn(raw, log.text(raw=True))
|
||||||
|
|
||||||
|
def test_ack_is_not_reported_as_applied(self):
|
||||||
|
log = ActivityLog()
|
||||||
|
log.record("log", "TX BLE 00 bb 01 11 00 05 60 02 ff 80 00 f8")
|
||||||
|
log.record("frame", Frame(0, 17, b""))
|
||||||
|
self.assertIn("acknowledged: Set lighting color", log.text())
|
||||||
|
self.assertNotIn("Speaker reports:", log.text())
|
||||||
|
|
||||||
|
def test_unknown_reply_is_honest_and_does_not_crash(self):
|
||||||
|
self.assertIn("Unmapped", describe_reply(b"\xff"))
|
||||||
|
self.assertIn("Unmapped", describe_reply(b""))
|
||||||
|
|
||||||
|
def test_history_is_bounded(self):
|
||||||
|
log = ActivityLog(limit=2)
|
||||||
|
for value in ("one", "two", "three"):
|
||||||
|
log.record("log", value)
|
||||||
|
self.assertEqual(log.text(), "two\nthree\n")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Model the named overloads exported by Python/WinRT, not C# overloads."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from controller.probe import SPP_UUID, check_channel, probe
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeTests(unittest.TestCase):
|
||||||
|
def test_projected_overloads_and_resource_cleanup(self):
|
||||||
|
closed = []
|
||||||
|
selector = "paired Bluetooth selector"
|
||||||
|
service = types.SimpleNamespace(
|
||||||
|
service_id=types.SimpleNamespace(uuid=SPP_UUID),
|
||||||
|
connection_host_name=types.SimpleNamespace(raw_name="speaker-host"),
|
||||||
|
connection_service_name="1",
|
||||||
|
close=lambda: closed.append("service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_services(cache_mode):
|
||||||
|
self.assertEqual(cache_mode, 1)
|
||||||
|
return types.SimpleNamespace(error=0, services=[service])
|
||||||
|
|
||||||
|
async def from_id(device_id):
|
||||||
|
self.assertEqual(device_id, "device-id")
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
bluetooth_address=0x123456789ABC,
|
||||||
|
get_rfcomm_services_with_cache_mode_async=get_services,
|
||||||
|
close=lambda: closed.append("device"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def find_filtered(value):
|
||||||
|
self.assertEqual(value, selector)
|
||||||
|
return [types.SimpleNamespace(name="MX-T40", id="device-id")]
|
||||||
|
|
||||||
|
def wrong_overload(value):
|
||||||
|
# The installed projection treats this argument as DeviceClass.
|
||||||
|
raise TypeError("'str' object cannot be interpreted as an integer")
|
||||||
|
|
||||||
|
bluetooth = types.SimpleNamespace(
|
||||||
|
BluetoothCacheMode=types.SimpleNamespace(UNCACHED=1),
|
||||||
|
BluetoothDevice=types.SimpleNamespace(
|
||||||
|
get_device_selector_from_pairing_state=lambda paired: selector,
|
||||||
|
from_id_async=from_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enumeration = types.SimpleNamespace(
|
||||||
|
DeviceInformation=types.SimpleNamespace(
|
||||||
|
find_all_async=wrong_overload,
|
||||||
|
find_all_async_aqs_filter=find_filtered,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
sys.modules,
|
||||||
|
{
|
||||||
|
"winrt.windows.devices.bluetooth": bluetooth,
|
||||||
|
"winrt.windows.devices.enumeration": enumeration,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contextlib.redirect_stdout(io.StringIO()) as output,
|
||||||
|
):
|
||||||
|
result = asyncio.run(probe("MX-T40"))
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertIn("SPP service found: True", output.getvalue())
|
||||||
|
self.assertEqual(closed, ["service", "device"])
|
||||||
|
|
||||||
|
def test_direct_channel_connects_without_sending(self):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
stream = MagicMock()
|
||||||
|
with (
|
||||||
|
patch("controller.probe.socket.socket") as create,
|
||||||
|
patch("controller.probe.socket.AF_BLUETOOTH", 32, create=True),
|
||||||
|
patch("controller.probe.socket.BTPROTO_RFCOMM", 3, create=True),
|
||||||
|
):
|
||||||
|
create.return_value.__enter__.return_value = stream
|
||||||
|
check_channel("12:34:56:78:9A:BC", 1)
|
||||||
|
stream.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
|
||||||
|
stream.settimeout.assert_called_once_with(10)
|
||||||
|
stream.send.assert_not_called()
|
||||||
|
stream.sendall.assert_not_called()
|
||||||
|
create.return_value.__exit__.assert_called_once()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Packet fixtures and stream boundaries independent of physical Bluetooth."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller.protocol import Decoder, Encoder
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolTests(unittest.TestCase):
|
||||||
|
def test_captured_mx_t40_connection_reply(self):
|
||||||
|
raw = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
|
||||||
|
frames = Decoder().feed(raw)
|
||||||
|
self.assertEqual(len(frames), 1)
|
||||||
|
self.assertEqual(frames[0].sequence, 0x1D)
|
||||||
|
self.assertEqual(frames[0].payload, bytes.fromhex("02 10 01 34 30 04 01 02 04 05 01"))
|
||||||
|
|
||||||
|
def test_captured_ble_source_and_eq_replies(self):
|
||||||
|
# These two consecutive hardware replies demonstrate both checksum
|
||||||
|
# variants: source excludes type, EQ includes it.
|
||||||
|
decoder = Decoder()
|
||||||
|
frames = decoder.feed(
|
||||||
|
bytes.fromhex("00 bb 01 6d 00 02 31 01 a1 00 bb 01 6e 00 04 41 03 02 00 b9")
|
||||||
|
)
|
||||||
|
self.assertEqual([frame.payload for frame in frames], [b"\x31\x01", b"\x41\x03\x02\x00"])
|
||||||
|
|
||||||
|
def test_captured_lighting_checksum_quirk(self):
|
||||||
|
for raw, mode in [
|
||||||
|
("00 bb 01 91 00 03 51 03 03 e9", 3),
|
||||||
|
("00 bb 01 ab 00 03 51 03 05 03", 5),
|
||||||
|
("00 bb 01 b0 00 03 51 03 06 08", 6),
|
||||||
|
("00 bb 01 b1 00 03 51 03 01 09", 1),
|
||||||
|
]:
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
frame = Decoder().feed(bytes.fromhex(raw))[0]
|
||||||
|
self.assertEqual(frame.payload, bytes((0x51, 3, mode)))
|
||||||
|
self.assertTrue(frame.checksum_valid)
|
||||||
|
|
||||||
|
def test_unknown_checksum_is_flagged_in_compatibility_mode(self):
|
||||||
|
raw = bytes.fromhex("00 bb 01 91 00 03 51 03 03 20")
|
||||||
|
self.assertEqual(Decoder().feed(raw), [])
|
||||||
|
frame = Decoder(strict_checksums=False).feed(raw)[0]
|
||||||
|
self.assertFalse(frame.checksum_valid)
|
||||||
|
|
||||||
|
def test_captured_play_pause_event_checksums(self):
|
||||||
|
for raw in ("00 bb 01 cd 00 02 70 11 40", "00 bb 01 ce 00 02 70 11 41"):
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
frame = Decoder().feed(bytes.fromhex(raw))[0]
|
||||||
|
self.assertEqual(frame.payload, b"\x70\x11")
|
||||||
|
self.assertTrue(frame.checksum_valid)
|
||||||
|
|
||||||
|
def test_app_connection_fixture(self):
|
||||||
|
self.assertEqual(Encoder().encode(b"\x01"), bytes.fromhex("00 bb 01 01 00 01 01 04"))
|
||||||
|
|
||||||
|
def test_signed_java_sequence_rollover(self):
|
||||||
|
encoder = Encoder()
|
||||||
|
for _ in range(127):
|
||||||
|
encoder.encode(b"\x01")
|
||||||
|
self.assertEqual(encoder.sequence, 127)
|
||||||
|
self.assertEqual(encoder.encode(b"\x01")[3], 1)
|
||||||
|
|
||||||
|
def test_every_split_and_multiple_frames(self):
|
||||||
|
# Peer replies use a different checksum rule than Encoder's commands.
|
||||||
|
first = bytes.fromhex("00 bb 01 01 00 04 41 03 00 02 4b")
|
||||||
|
second = bytes.fromhex("00 bb 01 02 00 03 51 03 01 5a")
|
||||||
|
for split in range(len(first) + 1):
|
||||||
|
decoder = Decoder()
|
||||||
|
frames = decoder.feed(first[:split]) + decoder.feed(first[split:] + second)
|
||||||
|
self.assertEqual([f.payload for f in frames], [b"\x41\x03\x00\x02", b"\x51\x03\x01"])
|
||||||
|
|
||||||
|
def test_corruption_and_noise_recovery(self):
|
||||||
|
bad = bytearray.fromhex("00 bb 01 01 00 01 01 03")
|
||||||
|
bad[-1] ^= 1
|
||||||
|
good = bytes.fromhex("00 bb 01 02 00 01 04 07")
|
||||||
|
frames = Decoder().feed(b"noise" + bad + good)
|
||||||
|
self.assertEqual([f.payload for f in frames], [b"\x04"])
|
||||||
|
|
||||||
|
def test_empty_ack(self):
|
||||||
|
frame = Decoder().feed(bytes.fromhex("00 bb 00 02 00 00 02"))[0]
|
||||||
|
self.assertEqual((frame.kind, frame.sequence, frame.payload), (0, 2, b""))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Exercise the session interface without a thread or transport."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller.session import Session
|
||||||
|
|
||||||
|
INFO = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTests(unittest.TestCase):
|
||||||
|
def test_captured_handshake_with_split_reply(self):
|
||||||
|
session = Session()
|
||||||
|
self.assertEqual(session.start(), bytes.fromhex("00 bb 01 01 00 01 01 04"))
|
||||||
|
self.assertFalse(session.receive(INFO[:9]).outgoing)
|
||||||
|
update = session.receive(INFO[9:])
|
||||||
|
self.assertEqual(update.outgoing, (bytes.fromhex("00 bb 01 02 00 01 04 08"),))
|
||||||
|
self.assertFalse(session.ready)
|
||||||
|
update = session.receive(bytes.fromhex("00 bb 00 02 00 00 02"))
|
||||||
|
self.assertTrue(update.became_ready)
|
||||||
|
self.assertTrue(session.ready)
|
||||||
|
self.assertFalse(session.receive(bytes.fromhex("00 bb 00 02 00 00 02")).became_ready)
|
||||||
|
|
||||||
|
def test_notification_sequence_cannot_complete_handshake(self):
|
||||||
|
session = Session()
|
||||||
|
session.start()
|
||||||
|
session.receive(INFO)
|
||||||
|
session.receive(bytes.fromhex("00 bb 01 02 00 02 31 01 36"))
|
||||||
|
self.assertFalse(session.ready)
|
||||||
|
|
||||||
|
def test_repeated_info_does_not_repeat_link_complete(self):
|
||||||
|
session = Session()
|
||||||
|
session.start()
|
||||||
|
self.assertEqual(len(session.receive(INFO).outgoing), 1)
|
||||||
|
self.assertEqual(session.receive(INFO).outgoing, ())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Decode actual status payloads without constructing a Tk window."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller.messages import describe_reply
|
||||||
|
|
||||||
|
|
||||||
|
class StatusTests(unittest.TestCase):
|
||||||
|
def test_captured_standard_eq(self):
|
||||||
|
self.assertEqual(describe_reply(bytes.fromhex("41 03 02 00")), "EQ: Standard")
|
||||||
|
|
||||||
|
def test_captured_volume_mute_and_lighting(self):
|
||||||
|
for raw, expected in [
|
||||||
|
("41 01 0b 00", "Volume: 11"),
|
||||||
|
("41 01 0c 00", "Volume: 12"),
|
||||||
|
("41 07 01 00", "Mute: On"),
|
||||||
|
("41 07 02 00", "Mute: Off"),
|
||||||
|
("51 03 03", "Lighting: Dance"),
|
||||||
|
]:
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(describe_reply(bytes.fromhex(raw)), expected)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Verify direct connections, write completion, and disconnect handling."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from controller.transport import RfcommStream, open_stream
|
||||||
|
|
||||||
|
|
||||||
|
class TransportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_socket = MagicMock()
|
||||||
|
# Replace this adapter's socket module reference rather than globally
|
||||||
|
# mocking socket.socket, which also breaks asyncio's internal sockets
|
||||||
|
# (particularly Windows' Proactor event loop).
|
||||||
|
isolated_socket = types.SimpleNamespace(
|
||||||
|
socket=self.mock_socket,
|
||||||
|
AF_BLUETOOTH=32,
|
||||||
|
BTPROTO_RFCOMM=3,
|
||||||
|
SOCK_STREAM=socket.SOCK_STREAM,
|
||||||
|
timeout=TimeoutError,
|
||||||
|
)
|
||||||
|
replacement = patch("controller.transport.socket", isolated_socket)
|
||||||
|
replacement.start()
|
||||||
|
self.addCleanup(replacement.stop)
|
||||||
|
self.peer = self.mock_socket.return_value
|
||||||
|
|
||||||
|
def test_direct_address_bypasses_serial_and_closes(self):
|
||||||
|
with patch.dict("sys.modules", {"serial": None}):
|
||||||
|
with open_stream("12:34:56:78:9A:BC", 1) as stream:
|
||||||
|
self.assertEqual(stream.write(b"packet"), 6)
|
||||||
|
self.peer.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
|
||||||
|
self.peer.sendall.assert_called_once_with(b"packet")
|
||||||
|
self.peer.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_idle_is_not_disconnect(self):
|
||||||
|
with RfcommStream("12:34:56:78:9A:BC") as stream:
|
||||||
|
self.peer.recv.side_effect = TimeoutError()
|
||||||
|
self.assertEqual(stream.read(1024), b"")
|
||||||
|
self.peer.recv.side_effect = None
|
||||||
|
self.peer.recv.return_value = b""
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
stream.read(1024)
|
||||||
|
|
||||||
|
def test_connect_failure_closes_socket(self):
|
||||||
|
self.peer.connect.side_effect = OSError("connection refused")
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
with RfcommStream("12:34:56:78:9A:BC"):
|
||||||
|
self.fail("Failed connection entered context")
|
||||||
|
self.peer.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_connect_timeout_identifies_phase_and_closes(self):
|
||||||
|
self.peer.connect.side_effect = TimeoutError("timed out")
|
||||||
|
with self.assertRaisesRegex(TimeoutError, "before the Samsung handshake"):
|
||||||
|
with RfcommStream("12:34:56:78:9A:BC"):
|
||||||
|
self.fail("Timed out connection entered context")
|
||||||
|
self.peer.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_write_timeout_identifies_phase(self):
|
||||||
|
with RfcommStream("12:34:56:78:9A:BC") as stream:
|
||||||
|
self.peer.sendall.side_effect = TimeoutError("timed out")
|
||||||
|
with self.assertRaisesRegex(TimeoutError, "sending a Samsung packet"):
|
||||||
|
stream.write(b"packet")
|
||||||
|
|
||||||
|
def test_prepared_connection_retains_windows_handle(self):
|
||||||
|
device = MagicMock()
|
||||||
|
prepare = AsyncMock(return_value=device)
|
||||||
|
report = MagicMock()
|
||||||
|
with patch("controller.windows_bluetooth.prepare_device", prepare):
|
||||||
|
with open_stream("12:34:56:78:9A:BC", 1, prepare=True, report=report):
|
||||||
|
device.close.assert_not_called()
|
||||||
|
prepare.assert_awaited_once_with("12:34:56:78:9A:BC", report)
|
||||||
|
self.peer.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
|
||||||
|
device.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_prepared_connect_failure_releases_both_handles(self):
|
||||||
|
device = MagicMock()
|
||||||
|
# The base adapter's socket cleanup is covered separately. Mock its
|
||||||
|
# entry here so asyncio's own internal sockets do not affect counts.
|
||||||
|
with (
|
||||||
|
patch("controller.windows_bluetooth.prepare_device", AsyncMock(return_value=device)),
|
||||||
|
patch.object(RfcommStream, "__enter__", side_effect=OSError("host down")),
|
||||||
|
):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
with open_stream("12:34:56:78:9A:BC", 1, prepare=True):
|
||||||
|
self.fail("Failed connection entered context")
|
||||||
|
device.close.assert_called_once()
|
||||||
Reference in new issue
Block a user