Skip to content

Commit 0b29e10

Browse files
TriskaeFilipe Doutel SilvaCopilotballoobclaude
authored
Add --manufacturer and --product-name flags (#206)
# Motivation When running sendspin daemon inside a Docker container (e.g. to turn a USB speaker into a Sendspin receiver), the device identity reported to the server in the client hello is not meaningful: - product_name is auto-detected from /etc/os-release — inside a container it ends up as something like "Debian GNU/Linux 12 (bookworm)" instead of a human-readable device name. - manufacturer is always None — there is no way to set it at all. This makes it hard to identify the physical device on the server side when multiple receivers are connected. # Changes New CLI flags (both sendspin and sendspin daemon): - --manufacturer — sets the manufacturer string sent in the client hello (e.g. "Acme Corp") - --product-name — overrides the auto-detected OS/platform name (e.g. "Living Room Speaker") New settings file keys (settings-tui.json / settings-daemon.json): - "manufacturer" — same as --manufacturer - "product_name" — same as --product-name CLI flags take precedence over the settings file. When neither is provided, the existing auto-detection behaviour is unchanged (non-breaking). Bug fixed in the process: --manufacturer and --product-name were missing from the daemon subparser (the daemon parser has its own argument list, separate from _add_player_runtime_options). This was caught by the new tests. Usage # CLI flags sendspin daemon \ --url ws://192.168.1.72:8927/sendspin \ --name "Living Room" \ --manufacturer "Acme" \ --product-name "Living Room Speaker" # Or via settings-daemon.json { "manufacturer": "Acme", "product_name": "Living Room Speaker" } Tests 26 new tests in tests/test_device_identity.py covering: - get_device_info() with each override combination - ClientSettings load/save round-trip for both fields - parse_args() for both flags on the TUI and daemon subparser - Precedence: CLI flag wins over settings file; missing flag falls back to settings; neither → None Manually tested against a Music Assistant server with both the CLI flags and the settings file approaches. --------- Co-authored-by: Filipe Doutel Silva <filipe@doutelsilva.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ad0278 commit 0b29e10

6 files changed

Lines changed: 105 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ Settings are stored in `~/.config/sendspin/`:
136136
"listen_port": 8927,
137137
"use_mpris": true,
138138
"use_hardware_volume": true,
139-
"hook_set_volume": "/usr/local/bin/set-avr-volume"
139+
"hook_set_volume": "/usr/local/bin/set-avr-volume",
140+
"manufacturer": "Acme Corp",
141+
"product_name": "Living Room Speaker"
140142
}
141143
```
142144

@@ -170,6 +172,8 @@ Settings are stored in `~/.config/sendspin/`:
170172
| `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument |
171173
| `hook_start` | string | TUI/daemon | Command to run when audio stream starts |
172174
| `hook_stop` | string | TUI/daemon | Command to run when audio stream stops |
175+
| `manufacturer` | string | TUI/daemon | Manufacturer name reported in the client hello (`--manufacturer`) |
176+
| `product_name` | string | TUI/daemon | Product name reported in the client hello (`--product-name`); defaults to auto-detected OS/platform name |
173177
| `source` | string | serve | Default audio source (file path or URL, ffmpeg input) |
174178
| `source_format` | string | serve | ffmpeg container format for audio source |
175179
| `clients` | array | serve | Client URLs to connect to (`--client`) |
@@ -304,6 +308,12 @@ sendspin daemon --name "Kitchen" --audio-device 2
304308

305309
In daemon mode without `--url`, the client listens for incoming server connections and advertises itself via mDNS. The `--name` option (or `name` setting) is used as the friendly name in the mDNS advertisement, making it easy for servers to identify this client on the network.
306310

311+
Use `--manufacturer` and `--product-name` to override the device identity reported to the server in the client hello. This is useful when running the daemon in a container or on a custom device where the auto-detected OS name is not meaningful:
312+
313+
```bash
314+
sendspin daemon --name "Living Room" --manufacturer "Acme" --product-name "Living Room Speaker"
315+
```
316+
307317
### Hooks
308318

309319
You can run external commands when audio streams start or stop. This is useful for controlling amplifiers, lighting, or other home automation:

‎sendspin/cli.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,18 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo
168168
metavar="{true,false}",
169169
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
170170
)
171+
target.add_argument(
172+
"--manufacturer",
173+
type=str,
174+
default=default,
175+
help="Manufacturer name reported in the client hello (e.g., 'Acme Corp')",
176+
)
177+
target.add_argument(
178+
"--product-name",
179+
type=str,
180+
default=default,
181+
help="Product name reported in the client hello (defaults to auto-detected OS/platform name)",
182+
)
171183
target.add_argument(
172184
"--hook-start",
173185
type=str,
@@ -393,6 +405,18 @@ def _build_parser() -> argparse.ArgumentParser:
393405
default=None,
394406
help="Command to run when audio stream stops (receives SENDSPIN_* env vars)",
395407
)
408+
daemon_parser.add_argument(
409+
"--manufacturer",
410+
type=str,
411+
default=None,
412+
help="Manufacturer name reported in the client hello (e.g., 'Acme Corp')",
413+
)
414+
daemon_parser.add_argument(
415+
"--product-name",
416+
type=str,
417+
default=None,
418+
help="Product name reported in the client hello (defaults to auto-detected OS/platform name)",
419+
)
396420

397421
return parser
398422

@@ -566,6 +590,8 @@ async def _run_daemon_mode(
566590
volume_controller=volume_controller,
567591
hook_start=args.hook_start,
568592
hook_stop=args.hook_stop,
593+
manufacturer=args.manufacturer,
594+
product_name=args.product_name,
569595
)
570596

571597
daemon = SendspinDaemon(daemon_args)
@@ -670,6 +696,10 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
670696
args.hook_start = settings.hook_start
671697
if args.hook_stop is None:
672698
args.hook_stop = settings.hook_stop
699+
if args.manufacturer is None:
700+
args.manufacturer = settings.manufacturer
701+
if args.product_name is None:
702+
args.product_name = settings.product_name
673703

674704
# Set up logging: daemon uses stderr, TUI writes to sendspin.log
675705
# so log output doesn't interfere with the Rich display.
@@ -733,6 +763,8 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
733763
volume_controller=volume_controller,
734764
hook_start=args.hook_start,
735765
hook_stop=args.hook_stop,
766+
manufacturer=args.manufacturer,
767+
product_name=args.product_name,
736768
)
737769

738770
app = SendspinApp(app_args)

‎sendspin/daemon/daemon.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class DaemonArgs:
5252
volume_controller: VolumeController | None = None
5353
hook_start: str | None = None
5454
hook_stop: str | None = None
55+
manufacturer: str | None = None
56+
product_name: str | None = None
5557

5658

5759
class SendspinDaemon:
@@ -90,7 +92,10 @@ def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient:
9092
client_id=self._args.client_id,
9193
client_name=self._args.client_name,
9294
roles=client_roles,
93-
device_info=get_device_info(),
95+
device_info=get_device_info(
96+
manufacturer=self._args.manufacturer,
97+
product_name=self._args.product_name,
98+
),
9499
player_support=ClientHelloPlayerSupport(
95100
supported_formats=supported_formats,
96101
buffer_capacity=32_000_000,

‎sendspin/settings.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class ClientSettings(BaseSettings):
119119
hook_start: str | None = None
120120
hook_stop: str | None = None
121121
visualizer: bool = False
122+
manufacturer: str | None = None
123+
product_name: str | None = None
122124

123125
def update(
124126
self,
@@ -202,6 +204,8 @@ def _load(self) -> None:
202204
self.hook_start = data.get("hook_start")
203205
self.hook_stop = data.get("hook_stop")
204206
self.visualizer = data.get("visualizer", False)
207+
self.manufacturer = data.get("manufacturer")
208+
self.product_name = data.get("product_name")
205209
logger.info(
206210
"Loaded settings from %s: volume=%d%%, muted=%s",
207211
self._settings_file,

‎sendspin/tui/app.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ class AppArgs:
228228
volume_controller: VolumeController | None = None
229229
hook_start: str | None = None
230230
hook_stop: str | None = None
231+
manufacturer: str | None = None
232+
product_name: str | None = None
231233

232234

233235
class SendspinApp:
@@ -292,7 +294,10 @@ def _create_client(self) -> SendspinClient:
292294
client_id=args.client_id,
293295
client_name=args.client_name,
294296
roles=roles,
295-
device_info=get_device_info(),
297+
device_info=get_device_info(
298+
manufacturer=args.manufacturer,
299+
product_name=args.product_name,
300+
),
296301
player_support=ClientHelloPlayerSupport(
297302
supported_formats=self._supported_formats,
298303
buffer_capacity=32_000_000,

‎sendspin/utils.py‎

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -65,38 +65,50 @@ def create_task(
6565
return task
6666

6767

68-
def get_device_info() -> DeviceInfo:
69-
"""Get device information for the client hello message."""
70-
# Get OS/platform information
71-
system = platform.system()
72-
product_name = f"{system}"
73-
74-
# Try to get more specific product info
75-
if system == "Linux":
76-
# Try reading /etc/os-release for distribution info
77-
try:
78-
os_release = Path("/etc/os-release")
79-
if os_release.exists():
80-
with os_release.open() as f:
81-
for line in f:
82-
if line.startswith("PRETTY_NAME="):
83-
product_name = line.split("=", 1)[1].strip().strip('"')
84-
break
85-
except (OSError, IndexError):
86-
pass
87-
elif system == "Darwin":
88-
mac_version = platform.mac_ver()[0]
89-
product_name = f"macOS {mac_version}" if mac_version else "macOS"
90-
elif system == "Windows":
91-
try:
92-
win_ver = platform.win32_ver()
93-
# Check build number to distinguish Windows 11 (build 22000+) from Windows 10
94-
if win_ver[0] == "10" and win_ver[1] and int(win_ver[1].split(".")[2]) >= 22000:
95-
product_name = "Windows 11"
96-
else:
97-
product_name = f"Windows {win_ver[0]}"
98-
except (ValueError, IndexError, AttributeError):
99-
product_name = f"Windows {platform.release()}"
68+
def get_device_info(
69+
*,
70+
manufacturer: str | None = None,
71+
product_name: str | None = None,
72+
) -> DeviceInfo:
73+
"""Get device information for the client hello message.
74+
75+
Args:
76+
manufacturer: Override the manufacturer field (default: None).
77+
product_name: Override the auto-detected product name.
78+
"""
79+
# Skip OS detection entirely when product_name is explicitly provided.
80+
if product_name is not None:
81+
detected_product_name = product_name
82+
else:
83+
system = platform.system()
84+
detected_product_name = f"{system}"
85+
86+
# Try to get more specific product info
87+
if system == "Linux":
88+
# Try reading /etc/os-release for distribution info
89+
try:
90+
os_release = Path("/etc/os-release")
91+
if os_release.exists():
92+
with os_release.open() as f:
93+
for line in f:
94+
if line.startswith("PRETTY_NAME="):
95+
detected_product_name = line.split("=", 1)[1].strip().strip('"')
96+
break
97+
except (OSError, IndexError):
98+
pass
99+
elif system == "Darwin":
100+
mac_version = platform.mac_ver()[0]
101+
detected_product_name = f"macOS {mac_version}" if mac_version else "macOS"
102+
elif system == "Windows":
103+
try:
104+
win_ver = platform.win32_ver()
105+
# Check build number to distinguish Windows 11 (build 22000+) from Windows 10
106+
if win_ver[0] == "10" and win_ver[1] and int(win_ver[1].split(".")[2]) >= 22000:
107+
detected_product_name = "Windows 11"
108+
else:
109+
detected_product_name = f"Windows {win_ver[0]}"
110+
except (ValueError, IndexError, AttributeError):
111+
detected_product_name = f"Windows {platform.release()}"
100112

101113
# Get software version
102114
try:
@@ -105,7 +117,7 @@ def get_device_info() -> DeviceInfo:
105117
software_version = "aiosendspin (unknown version)"
106118

107119
return DeviceInfo(
108-
product_name=product_name,
109-
manufacturer=None, # Could add manufacturer detection if needed
120+
product_name=detected_product_name,
121+
manufacturer=manufacturer,
110122
software_version=software_version,
111123
)

0 commit comments

Comments
 (0)