pulse5ctl 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse5ctl-1.3.0/PKG-INFO +9 -0
- pulse5ctl-1.3.0/pulse5/__init__.py +0 -0
- pulse5ctl-1.3.0/pulse5/ble.py +142 -0
- pulse5ctl-1.3.0/pulse5/cli.py +155 -0
- pulse5ctl-1.3.0/pulse5/config.py +44 -0
- pulse5ctl-1.3.0/pulse5/mcp_server.py +62 -0
- pulse5ctl-1.3.0/pulse5/protocol/__init__.py +0 -0
- pulse5ctl-1.3.0/pulse5/protocol/codec.py +110 -0
- pulse5ctl-1.3.0/pulse5/protocol/constants.py +26 -0
- pulse5ctl-1.3.0/pulse5/protocol/models.py +138 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/PKG-INFO +9 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/SOURCES.txt +19 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/dependency_links.txt +1 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/entry_points.txt +3 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/requires.txt +3 -0
- pulse5ctl-1.3.0/pulse5ctl.egg-info/top_level.txt +1 -0
- pulse5ctl-1.3.0/pyproject.toml +25 -0
- pulse5ctl-1.3.0/setup.cfg +4 -0
- pulse5ctl-1.3.0/tests/test_codec.py +103 -0
- pulse5ctl-1.3.0/tests/test_config.py +42 -0
- pulse5ctl-1.3.0/tests/test_models.py +98 -0
pulse5ctl-1.3.0/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import platform
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import AsyncIterator, Callable
|
|
8
|
+
|
|
9
|
+
from bleak import BleakClient, BleakScanner
|
|
10
|
+
from bleak.backends.device import BLEDevice
|
|
11
|
+
from bleak.backends.scanner import AdvertisementData
|
|
12
|
+
|
|
13
|
+
from pulse5.protocol.constants import PulseConstants as C
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DiscoveredDevice:
|
|
18
|
+
address: str
|
|
19
|
+
name: str
|
|
20
|
+
rssi: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _find_connected_peripherals(scanner: BleakScanner) -> list[DiscoveredDevice]:
|
|
24
|
+
if platform.system() != "Darwin":
|
|
25
|
+
return []
|
|
26
|
+
try:
|
|
27
|
+
import CoreBluetooth
|
|
28
|
+
|
|
29
|
+
cbmgr = scanner._backend._manager
|
|
30
|
+
service = CoreBluetooth.CBUUID.UUIDWithString_(C.SERVICE_UUID)
|
|
31
|
+
peripherals = cbmgr.central_manager.retrieveConnectedPeripheralsWithServices_(
|
|
32
|
+
[service]
|
|
33
|
+
)
|
|
34
|
+
results = []
|
|
35
|
+
for p in peripherals:
|
|
36
|
+
name = str(p.name()) if p.name() else "JBL Pulse 5"
|
|
37
|
+
uuid = str(p.identifier())
|
|
38
|
+
results.append(DiscoveredDevice(address=uuid, name=name, rssi=0))
|
|
39
|
+
return results
|
|
40
|
+
except Exception:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def scan(timeout: float = 5.0) -> list[DiscoveredDevice]:
|
|
45
|
+
devices: dict[str, DiscoveredDevice] = {}
|
|
46
|
+
|
|
47
|
+
def callback(device: BLEDevice, adv: AdvertisementData) -> None:
|
|
48
|
+
name = adv.local_name or device.name or ""
|
|
49
|
+
has_service = C.SERVICE_UUID.lower() in [s.lower() for s in (adv.service_uuids or [])]
|
|
50
|
+
is_jbl = has_service or name.startswith(C.DEVICE_NAME_PREFIX)
|
|
51
|
+
if is_jbl:
|
|
52
|
+
devices[device.address] = DiscoveredDevice(
|
|
53
|
+
address=device.address,
|
|
54
|
+
name=name or "Unknown",
|
|
55
|
+
rssi=adv.rssi if adv.rssi is not None else -100,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
scanner = BleakScanner(detection_callback=callback)
|
|
59
|
+
await scanner.start()
|
|
60
|
+
|
|
61
|
+
for dev in await _find_connected_peripherals(scanner):
|
|
62
|
+
devices[dev.address] = dev
|
|
63
|
+
|
|
64
|
+
await asyncio.sleep(timeout)
|
|
65
|
+
await scanner.stop()
|
|
66
|
+
|
|
67
|
+
return sorted(devices.values(), key=lambda d: d.rssi, reverse=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _resolve_device(address: str) -> BLEDevice | str:
|
|
71
|
+
if platform.system() != "Darwin":
|
|
72
|
+
return address
|
|
73
|
+
try:
|
|
74
|
+
from Foundation import NSUUID
|
|
75
|
+
|
|
76
|
+
scanner = BleakScanner(service_uuids=[C.SERVICE_UUID])
|
|
77
|
+
await scanner.start()
|
|
78
|
+
await asyncio.sleep(0.5)
|
|
79
|
+
|
|
80
|
+
cbmgr = scanner._backend._manager
|
|
81
|
+
cm = cbmgr.central_manager
|
|
82
|
+
|
|
83
|
+
ns_uuid = NSUUID.alloc().initWithUUIDString_(address)
|
|
84
|
+
peripherals = cm.retrievePeripheralsWithIdentifiers_([ns_uuid])
|
|
85
|
+
|
|
86
|
+
await scanner.stop()
|
|
87
|
+
|
|
88
|
+
if peripherals and len(peripherals) > 0:
|
|
89
|
+
peripheral = peripherals[0]
|
|
90
|
+
return BLEDevice(
|
|
91
|
+
address=str(peripheral.identifier()),
|
|
92
|
+
name=str(peripheral.name()) if peripheral.name() else "JBL Pulse 5",
|
|
93
|
+
details=(peripheral, cbmgr),
|
|
94
|
+
)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return address
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def connect(address: str) -> AsyncIterator[BleakClient]:
|
|
102
|
+
last_error: Exception | None = None
|
|
103
|
+
device = await _resolve_device(address)
|
|
104
|
+
|
|
105
|
+
for attempt in range(C.MAX_RECONNECT_ATTEMPTS):
|
|
106
|
+
try:
|
|
107
|
+
client = BleakClient(device)
|
|
108
|
+
await client.connect()
|
|
109
|
+
try:
|
|
110
|
+
yield client
|
|
111
|
+
finally:
|
|
112
|
+
if client.is_connected:
|
|
113
|
+
await client.disconnect()
|
|
114
|
+
return
|
|
115
|
+
except Exception as e:
|
|
116
|
+
last_error = e
|
|
117
|
+
if attempt < C.MAX_RECONNECT_ATTEMPTS - 1:
|
|
118
|
+
delay = C.BASE_RECONNECT_DELAY * (2 ** attempt)
|
|
119
|
+
await asyncio.sleep(delay)
|
|
120
|
+
|
|
121
|
+
raise ConnectionError(
|
|
122
|
+
f"Failed to connect after {C.MAX_RECONNECT_ATTEMPTS} attempts: {last_error}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def write(client: BleakClient, data: bytes) -> None:
|
|
127
|
+
char = client.services.get_characteristic(C.WRITE_CHAR_UUID)
|
|
128
|
+
if char is None:
|
|
129
|
+
raise RuntimeError(f"Write characteristic {C.WRITE_CHAR_UUID} not found")
|
|
130
|
+
response = "write" in char.properties
|
|
131
|
+
await client.write_gatt_char(char, data, response=response)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def subscribe(client: BleakClient, callback: Callable[[bytearray], None]) -> None:
|
|
135
|
+
char = client.services.get_characteristic(C.READ_CHAR_UUID)
|
|
136
|
+
if char is None:
|
|
137
|
+
raise RuntimeError(f"Read characteristic {C.READ_CHAR_UUID} not found")
|
|
138
|
+
|
|
139
|
+
def handler(_sender: int, data: bytearray) -> None:
|
|
140
|
+
callback(data)
|
|
141
|
+
|
|
142
|
+
await client.start_notify(char, handler)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from pulse5 import ble, config
|
|
9
|
+
from pulse5.protocol.codec import PulseCodec
|
|
10
|
+
from pulse5.protocol.models import PATTERN_BY_NAME, THEME_BY_NAME, ColorEffect
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(coro):
|
|
14
|
+
return asyncio.run(coro)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_address(address: str | None) -> str:
|
|
18
|
+
if address:
|
|
19
|
+
return address
|
|
20
|
+
saved = config.get_saved_device()
|
|
21
|
+
if saved:
|
|
22
|
+
return saved[0]
|
|
23
|
+
click.echo("Error: No device specified. Run 'pulse5 scan' first or pass --address.", err=True)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
@click.option("--address", "-a", default=None, help="Device BLE address.")
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def cli(ctx: click.Context, address: str | None) -> None:
|
|
31
|
+
"""Control JBL Pulse 5 speaker LEDs over Bluetooth."""
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["address"] = address
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cli.command()
|
|
37
|
+
@click.option("--timeout", "-t", default=5.0, help="Scan duration in seconds.")
|
|
38
|
+
def scan(timeout: float) -> None:
|
|
39
|
+
"""Discover nearby JBL Pulse 5 speakers."""
|
|
40
|
+
|
|
41
|
+
async def _scan():
|
|
42
|
+
click.echo(f"Scanning for {timeout}s...")
|
|
43
|
+
devices = await ble.scan(timeout=timeout)
|
|
44
|
+
if not devices:
|
|
45
|
+
click.echo("No JBL Pulse 5 speakers found.")
|
|
46
|
+
return
|
|
47
|
+
click.echo(f"Found {len(devices)} device(s):\n")
|
|
48
|
+
for d in devices:
|
|
49
|
+
click.echo(f" {d.name}")
|
|
50
|
+
click.echo(f" Address: {d.address}")
|
|
51
|
+
click.echo(f" RSSI: {d.rssi} dBm")
|
|
52
|
+
click.echo()
|
|
53
|
+
best = devices[0]
|
|
54
|
+
config.save_device(best.address, best.name)
|
|
55
|
+
click.echo(f"Saved '{best.name}' ({best.address}) as default device.")
|
|
56
|
+
|
|
57
|
+
_run(_scan())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command()
|
|
61
|
+
@click.argument("level", type=click.IntRange(20, 80))
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def brightness(ctx: click.Context, level: int) -> None:
|
|
64
|
+
"""Set brightness level (20-80)."""
|
|
65
|
+
address = _get_address(ctx.obj["address"])
|
|
66
|
+
|
|
67
|
+
async def _cmd():
|
|
68
|
+
async with ble.connect(address) as client:
|
|
69
|
+
await ble.write(client, PulseCodec.set_led_brightness(level))
|
|
70
|
+
click.echo(f"Brightness set to {level}.")
|
|
71
|
+
|
|
72
|
+
_run(_cmd())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cli.command()
|
|
76
|
+
@click.argument("name", type=click.Choice(list(THEME_BY_NAME.keys()), case_sensitive=False))
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def theme(ctx: click.Context, name: str) -> None:
|
|
79
|
+
"""Set LED theme."""
|
|
80
|
+
address = _get_address(ctx.obj["address"])
|
|
81
|
+
t = THEME_BY_NAME[name.lower()]
|
|
82
|
+
|
|
83
|
+
async def _cmd():
|
|
84
|
+
async with ble.connect(address) as client:
|
|
85
|
+
await ble.write(client, PulseCodec.switch_package(t.value))
|
|
86
|
+
click.echo(f"Theme set to {t.display_name}.")
|
|
87
|
+
|
|
88
|
+
_run(_cmd())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
@click.argument("name", type=click.Choice(list(PATTERN_BY_NAME.keys()), case_sensitive=False))
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def pattern(ctx: click.Context, name: str) -> None:
|
|
95
|
+
"""Set an LED pattern."""
|
|
96
|
+
address = _get_address(ctx.obj["address"])
|
|
97
|
+
p = PATTERN_BY_NAME[name.lower()]
|
|
98
|
+
t = p.theme
|
|
99
|
+
all_pats = [pat.value for pat in t.patterns]
|
|
100
|
+
ordered = [p.value] + [v for v in all_pats if v != p.value]
|
|
101
|
+
|
|
102
|
+
async def _cmd():
|
|
103
|
+
async with ble.connect(address) as client:
|
|
104
|
+
await ble.write(client, PulseCodec.set_led_package(
|
|
105
|
+
package_id=t.value,
|
|
106
|
+
active_patterns=[p.value],
|
|
107
|
+
all_patterns=ordered,
|
|
108
|
+
color_effect=ColorEffect.COLOR_LOOP.value,
|
|
109
|
+
red=0xFF, green=0xFF, blue=0xFF,
|
|
110
|
+
))
|
|
111
|
+
click.echo(f"Pattern set to {p.display_name}.")
|
|
112
|
+
|
|
113
|
+
_run(_cmd())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cli.command()
|
|
117
|
+
@click.pass_context
|
|
118
|
+
def status(ctx: click.Context) -> None:
|
|
119
|
+
"""Query current speaker state."""
|
|
120
|
+
address = _get_address(ctx.obj["address"])
|
|
121
|
+
|
|
122
|
+
async def _cmd():
|
|
123
|
+
results: dict[str, object] = {}
|
|
124
|
+
|
|
125
|
+
def on_notify(data: bytearray) -> None:
|
|
126
|
+
brt = PulseCodec.parse_brightness_state(data)
|
|
127
|
+
if brt is not None:
|
|
128
|
+
results["brightness"] = brt
|
|
129
|
+
thm = PulseCodec.parse_selected_theme(data)
|
|
130
|
+
if thm is not None:
|
|
131
|
+
results["theme"] = thm
|
|
132
|
+
|
|
133
|
+
async with ble.connect(address) as client:
|
|
134
|
+
await ble.subscribe(client, on_notify)
|
|
135
|
+
await ble.write(client, PulseCodec.request_led_brightness())
|
|
136
|
+
await asyncio.sleep(0.5)
|
|
137
|
+
await ble.write(client, PulseCodec.request_led_package_info())
|
|
138
|
+
await asyncio.sleep(0.5)
|
|
139
|
+
|
|
140
|
+
click.echo("Speaker Status:")
|
|
141
|
+
if "brightness" in results:
|
|
142
|
+
brt = results["brightness"]
|
|
143
|
+
click.echo(f" Brightness: {brt.level}")
|
|
144
|
+
if "theme" in results:
|
|
145
|
+
click.echo(f" Theme: {results['theme'].display_name}")
|
|
146
|
+
|
|
147
|
+
_run(_cmd())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main() -> None:
|
|
151
|
+
cli()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".config" / "pulse5"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load() -> dict:
|
|
11
|
+
if CONFIG_FILE.exists():
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
14
|
+
except (json.JSONDecodeError, OSError):
|
|
15
|
+
return {}
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _save(data: dict) -> None:
|
|
20
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_saved_device() -> tuple[str, str] | None:
|
|
25
|
+
data = _load()
|
|
26
|
+
addr = data.get("address")
|
|
27
|
+
name = data.get("name")
|
|
28
|
+
if addr:
|
|
29
|
+
return (addr, name or "Unknown")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_device(address: str, name: str) -> None:
|
|
34
|
+
data = _load()
|
|
35
|
+
data["address"] = address
|
|
36
|
+
data["name"] = name
|
|
37
|
+
_save(data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear_device() -> None:
|
|
41
|
+
data = _load()
|
|
42
|
+
data.pop("address", None)
|
|
43
|
+
data.pop("name", None)
|
|
44
|
+
_save(data)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
mcp = FastMCP("pulse5")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _run_pulse5(*args: str) -> str:
|
|
12
|
+
proc = await asyncio.create_subprocess_exec(
|
|
13
|
+
sys.executable, "-m", "pulse5.cli", *args,
|
|
14
|
+
stdout=asyncio.subprocess.PIPE,
|
|
15
|
+
stderr=asyncio.subprocess.PIPE,
|
|
16
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
17
|
+
start_new_session=True,
|
|
18
|
+
)
|
|
19
|
+
stdout, stderr = await proc.communicate()
|
|
20
|
+
output = stdout.decode().strip()
|
|
21
|
+
if proc.returncode != 0:
|
|
22
|
+
err = stderr.decode().strip()
|
|
23
|
+
return f"Failed: {err or output or f'exit code {proc.returncode}'}"
|
|
24
|
+
return output or "Done."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
async def pulse5_scan(timeout: float = 5) -> str:
|
|
29
|
+
"""Discover nearby JBL Pulse 5 speakers via BLE."""
|
|
30
|
+
return await _run_pulse5("scan", "--timeout", str(timeout))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp.tool()
|
|
34
|
+
async def pulse5_brightness(level: int) -> str:
|
|
35
|
+
"""Set speaker LED brightness (20-80)."""
|
|
36
|
+
return await _run_pulse5("brightness", str(level))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
async def pulse5_theme(name: str) -> str:
|
|
41
|
+
"""Set LED theme. Options: nature, party, spiritual, cocktail, weather."""
|
|
42
|
+
return await _run_pulse5("theme", name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
async def pulse5_pattern(name: str) -> str:
|
|
47
|
+
"""Set LED pattern. Options: campfire, northern lights, sea wave, universe, strobe, equalizer, geometry, spin, rainbow, dynamic wave, lava, focus, sky sunny, rain, snow, thunder, cloud, fruit gin, mojito, tequila, cherry."""
|
|
48
|
+
return await _run_pulse5("pattern", name)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
async def pulse5_status() -> str:
|
|
53
|
+
"""Query current speaker state (brightness, theme)."""
|
|
54
|
+
return await _run_pulse5("status")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
mcp.run()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pulse5.protocol.constants import PulseConstants as C
|
|
6
|
+
from pulse5.protocol.models import LEDTheme
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Response:
|
|
11
|
+
command_id: int
|
|
12
|
+
payload: bytes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BrightnessState:
|
|
17
|
+
level: int
|
|
18
|
+
body_light_on: bool
|
|
19
|
+
projection_on: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PulseCodec:
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def request_speaker_info() -> bytes:
|
|
26
|
+
return bytes([C.HEADER, C.CMD_REQ_SPEAKER_INFO, 0x00])
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def switch_package(package_id: int) -> bytes:
|
|
30
|
+
return bytes([C.HEADER, C.CMD_SWITCH_LED_PACKAGE, 0x01, package_id])
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def set_led_brightness(level: int, body_light: bool = True, projection: bool = True) -> bytes:
|
|
34
|
+
clamped = min(max(level, C.MIN_BRIGHTNESS), C.MAX_BRIGHTNESS)
|
|
35
|
+
return bytes([
|
|
36
|
+
C.HEADER,
|
|
37
|
+
C.CMD_SET_LED_BRIGHTNESS,
|
|
38
|
+
0x03,
|
|
39
|
+
clamped,
|
|
40
|
+
1 if body_light else 0,
|
|
41
|
+
1 if projection else 0,
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def request_led_brightness() -> bytes:
|
|
46
|
+
return bytes([C.HEADER, C.CMD_REQ_LED_BRIGHTNESS, 0x00])
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def request_led_package_info() -> bytes:
|
|
50
|
+
return bytes([C.HEADER, C.CMD_REQ_LED_PACKAGE_INFO, 0x00])
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def set_led_package(
|
|
54
|
+
package_id: int,
|
|
55
|
+
active_patterns: list[int],
|
|
56
|
+
all_patterns: list[int],
|
|
57
|
+
color_effect: int,
|
|
58
|
+
red: int,
|
|
59
|
+
green: int,
|
|
60
|
+
blue: int,
|
|
61
|
+
) -> bytes:
|
|
62
|
+
all_count = len(all_patterns)
|
|
63
|
+
active_count = len(active_patterns)
|
|
64
|
+
length = all_count + 7
|
|
65
|
+
|
|
66
|
+
data = bytearray([
|
|
67
|
+
C.HEADER,
|
|
68
|
+
C.CMD_SET_LED_PACKAGE,
|
|
69
|
+
length,
|
|
70
|
+
package_id,
|
|
71
|
+
active_count,
|
|
72
|
+
all_count,
|
|
73
|
+
])
|
|
74
|
+
data.extend(all_patterns)
|
|
75
|
+
data.extend([color_effect, red, green, blue])
|
|
76
|
+
return bytes(data)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def parse(data: bytes | bytearray) -> Response | None:
|
|
80
|
+
if len(data) < 3 or data[0] != C.HEADER:
|
|
81
|
+
return None
|
|
82
|
+
command_id = data[1]
|
|
83
|
+
length = data[2]
|
|
84
|
+
payload = bytes(data[3 : 3 + length])
|
|
85
|
+
return Response(command_id=command_id, payload=payload)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def parse_brightness_state(data: bytes | bytearray) -> BrightnessState | None:
|
|
89
|
+
response = PulseCodec.parse(data)
|
|
90
|
+
if response is None or response.command_id != C.CMD_RET_LED_BRIGHTNESS:
|
|
91
|
+
return None
|
|
92
|
+
if len(response.payload) < 3:
|
|
93
|
+
return None
|
|
94
|
+
return BrightnessState(
|
|
95
|
+
level=response.payload[0],
|
|
96
|
+
body_light_on=response.payload[1] != 0,
|
|
97
|
+
projection_on=response.payload[2] != 0,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def parse_selected_theme(data: bytes | bytearray) -> LEDTheme | None:
|
|
102
|
+
response = PulseCodec.parse(data)
|
|
103
|
+
if response is None or response.command_id != C.CMD_RET_LED_PACKAGE_INFO:
|
|
104
|
+
return None
|
|
105
|
+
if len(response.payload) < 2:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return LEDTheme(response.payload[1])
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class PulseConstants:
|
|
2
|
+
SERVICE_UUID = "65786365-6c70-6f69-6e74-2e636f6d0000"
|
|
3
|
+
WRITE_CHAR_UUID = "65786365-6c70-6f69-6e74-2e636f6d0002"
|
|
4
|
+
READ_CHAR_UUID = "65786365-6c70-6f69-6e74-2e636f6d0001"
|
|
5
|
+
|
|
6
|
+
HEADER = 0xAA
|
|
7
|
+
|
|
8
|
+
CMD_REQ_SPEAKER_INFO = 0x11
|
|
9
|
+
|
|
10
|
+
CMD_REQ_LED_PACKAGE_INFO = 0x83
|
|
11
|
+
CMD_RET_LED_PACKAGE_INFO = 0x84
|
|
12
|
+
CMD_SET_LED_PACKAGE = 0x85
|
|
13
|
+
|
|
14
|
+
CMD_SET_LED_BRIGHTNESS = 0x8A
|
|
15
|
+
CMD_REQ_LED_BRIGHTNESS = 0x8B
|
|
16
|
+
CMD_RET_LED_BRIGHTNESS = 0x8C
|
|
17
|
+
|
|
18
|
+
CMD_SWITCH_LED_PACKAGE = 0x90
|
|
19
|
+
|
|
20
|
+
MIN_BRIGHTNESS = 20
|
|
21
|
+
MAX_BRIGHTNESS = 80
|
|
22
|
+
|
|
23
|
+
DEVICE_NAME_PREFIX = "JBL"
|
|
24
|
+
|
|
25
|
+
MAX_RECONNECT_ATTEMPTS = 3
|
|
26
|
+
BASE_RECONNECT_DELAY = 1.0
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LEDTheme(IntEnum):
|
|
5
|
+
NATURE = 0x01
|
|
6
|
+
PARTY = 0x02
|
|
7
|
+
SPIRITUAL = 0x03
|
|
8
|
+
COCKTAIL = 0x04
|
|
9
|
+
WEATHER = 0x05
|
|
10
|
+
CANVAS = 0xC1
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def display_name(self) -> str:
|
|
14
|
+
return _THEME_NAMES[self]
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def patterns(self) -> list["LEDPattern"]:
|
|
18
|
+
return list(_THEME_PATTERNS[self])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LEDPattern(IntEnum):
|
|
22
|
+
CAMPFIRE = 0x01
|
|
23
|
+
NORTHERN_LIGHTS = 0x02
|
|
24
|
+
SEA_WAVE = 0x03
|
|
25
|
+
UNIVERSE = 0x04
|
|
26
|
+
STROBE = 0x05
|
|
27
|
+
EQUALIZER = 0x06
|
|
28
|
+
GEOMETRY = 0x07
|
|
29
|
+
SPIN = 0x08
|
|
30
|
+
RAINBOW = 0x09
|
|
31
|
+
DYNAMIC_WAVE = 0x0A
|
|
32
|
+
LAVA = 0x0B
|
|
33
|
+
FOCUS = 0x0C
|
|
34
|
+
SKY_SUNNY = 0x0D
|
|
35
|
+
RAIN = 0x0E
|
|
36
|
+
SNOW = 0x0F
|
|
37
|
+
THUNDER = 0x10
|
|
38
|
+
CLOUD = 0x11
|
|
39
|
+
FRUIT_GIN = 0x13
|
|
40
|
+
MOJITO = 0x14
|
|
41
|
+
TEQUILA = 0x15
|
|
42
|
+
CHERRY = 0x16
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def display_name(self) -> str:
|
|
46
|
+
return _PATTERN_NAMES[self]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def theme(self) -> LEDTheme:
|
|
50
|
+
return _PATTERN_TO_THEME[self]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ColorEffect(IntEnum):
|
|
54
|
+
STATIC = 0
|
|
55
|
+
COLOR_LOOP = 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_THEME_NAMES: dict[LEDTheme, str] = {
|
|
59
|
+
LEDTheme.NATURE: "Nature",
|
|
60
|
+
LEDTheme.PARTY: "Party",
|
|
61
|
+
LEDTheme.SPIRITUAL: "Spiritual",
|
|
62
|
+
LEDTheme.COCKTAIL: "Cocktail",
|
|
63
|
+
LEDTheme.WEATHER: "Weather",
|
|
64
|
+
LEDTheme.CANVAS: "Canvas",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_PATTERN_NAMES: dict[LEDPattern, str] = {
|
|
68
|
+
LEDPattern.CAMPFIRE: "Campfire",
|
|
69
|
+
LEDPattern.NORTHERN_LIGHTS: "Northern Lights",
|
|
70
|
+
LEDPattern.SEA_WAVE: "Sea Wave",
|
|
71
|
+
LEDPattern.UNIVERSE: "Universe",
|
|
72
|
+
LEDPattern.STROBE: "Strobe",
|
|
73
|
+
LEDPattern.EQUALIZER: "Equalizer",
|
|
74
|
+
LEDPattern.GEOMETRY: "Geometry",
|
|
75
|
+
LEDPattern.SPIN: "Spin",
|
|
76
|
+
LEDPattern.RAINBOW: "Rainbow",
|
|
77
|
+
LEDPattern.DYNAMIC_WAVE: "Dynamic Wave",
|
|
78
|
+
LEDPattern.LAVA: "Lava",
|
|
79
|
+
LEDPattern.FOCUS: "Focus",
|
|
80
|
+
LEDPattern.SKY_SUNNY: "Sky Sunny",
|
|
81
|
+
LEDPattern.RAIN: "Rain",
|
|
82
|
+
LEDPattern.SNOW: "Snow",
|
|
83
|
+
LEDPattern.THUNDER: "Thunder",
|
|
84
|
+
LEDPattern.CLOUD: "Cloud",
|
|
85
|
+
LEDPattern.FRUIT_GIN: "Fruit Gin",
|
|
86
|
+
LEDPattern.MOJITO: "Mojito",
|
|
87
|
+
LEDPattern.TEQUILA: "Tequila",
|
|
88
|
+
LEDPattern.CHERRY: "Cherry",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_THEME_PATTERNS: dict[LEDTheme, tuple[LEDPattern, ...]] = {
|
|
92
|
+
LEDTheme.NATURE: (
|
|
93
|
+
LEDPattern.CAMPFIRE,
|
|
94
|
+
LEDPattern.NORTHERN_LIGHTS,
|
|
95
|
+
LEDPattern.SEA_WAVE,
|
|
96
|
+
LEDPattern.UNIVERSE,
|
|
97
|
+
),
|
|
98
|
+
LEDTheme.PARTY: (
|
|
99
|
+
LEDPattern.STROBE,
|
|
100
|
+
LEDPattern.EQUALIZER,
|
|
101
|
+
LEDPattern.GEOMETRY,
|
|
102
|
+
LEDPattern.SPIN,
|
|
103
|
+
LEDPattern.RAINBOW,
|
|
104
|
+
),
|
|
105
|
+
LEDTheme.SPIRITUAL: (
|
|
106
|
+
LEDPattern.DYNAMIC_WAVE,
|
|
107
|
+
LEDPattern.LAVA,
|
|
108
|
+
LEDPattern.FOCUS,
|
|
109
|
+
),
|
|
110
|
+
LEDTheme.COCKTAIL: (
|
|
111
|
+
LEDPattern.FRUIT_GIN,
|
|
112
|
+
LEDPattern.MOJITO,
|
|
113
|
+
LEDPattern.TEQUILA,
|
|
114
|
+
LEDPattern.CHERRY,
|
|
115
|
+
),
|
|
116
|
+
LEDTheme.WEATHER: (
|
|
117
|
+
LEDPattern.SKY_SUNNY,
|
|
118
|
+
LEDPattern.RAIN,
|
|
119
|
+
LEDPattern.SNOW,
|
|
120
|
+
LEDPattern.THUNDER,
|
|
121
|
+
LEDPattern.CLOUD,
|
|
122
|
+
),
|
|
123
|
+
LEDTheme.CANVAS: (),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_PATTERN_TO_THEME: dict[LEDPattern, LEDTheme] = {
|
|
127
|
+
pattern: theme
|
|
128
|
+
for theme, patterns in _THEME_PATTERNS.items()
|
|
129
|
+
for pattern in patterns
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
THEME_BY_NAME: dict[str, LEDTheme] = {
|
|
133
|
+
t.display_name.lower(): t for t in LEDTheme if t != LEDTheme.CANVAS
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
PATTERN_BY_NAME: dict[str, LEDPattern] = {
|
|
137
|
+
p.display_name.lower(): p for p in LEDPattern
|
|
138
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pulse5/__init__.py
|
|
3
|
+
pulse5/ble.py
|
|
4
|
+
pulse5/cli.py
|
|
5
|
+
pulse5/config.py
|
|
6
|
+
pulse5/mcp_server.py
|
|
7
|
+
pulse5/protocol/__init__.py
|
|
8
|
+
pulse5/protocol/codec.py
|
|
9
|
+
pulse5/protocol/constants.py
|
|
10
|
+
pulse5/protocol/models.py
|
|
11
|
+
pulse5ctl.egg-info/PKG-INFO
|
|
12
|
+
pulse5ctl.egg-info/SOURCES.txt
|
|
13
|
+
pulse5ctl.egg-info/dependency_links.txt
|
|
14
|
+
pulse5ctl.egg-info/entry_points.txt
|
|
15
|
+
pulse5ctl.egg-info/requires.txt
|
|
16
|
+
pulse5ctl.egg-info/top_level.txt
|
|
17
|
+
tests/test_codec.py
|
|
18
|
+
tests/test_config.py
|
|
19
|
+
tests/test_models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pulse5
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pulse5ctl"
|
|
7
|
+
version = "1.3.0"
|
|
8
|
+
description = "CLI & MCP server for JBL Pulse 5 LED control over BLE"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"bleak>=0.21.0",
|
|
13
|
+
"click>=8.0",
|
|
14
|
+
"mcp>=1.2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
pulse5 = "pulse5.cli:main"
|
|
19
|
+
pulse5-mcp = "pulse5.mcp_server:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
include = ["pulse5*"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pulse5.protocol.codec import PulseCodec, BrightnessState
|
|
2
|
+
from pulse5.protocol.constants import PulseConstants as C
|
|
3
|
+
from pulse5.protocol.models import LEDTheme
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestEncoding:
|
|
7
|
+
def test_set_led_brightness(self):
|
|
8
|
+
assert PulseCodec.set_led_brightness(60) == bytes([0xAA, 0x8A, 0x03, 60, 1, 1])
|
|
9
|
+
|
|
10
|
+
def test_set_led_brightness_clamps_low(self):
|
|
11
|
+
assert PulseCodec.set_led_brightness(0)[3] == C.MIN_BRIGHTNESS
|
|
12
|
+
|
|
13
|
+
def test_set_led_brightness_clamps_high(self):
|
|
14
|
+
assert PulseCodec.set_led_brightness(255)[3] == C.MAX_BRIGHTNESS
|
|
15
|
+
|
|
16
|
+
def test_set_led_brightness_body_off(self):
|
|
17
|
+
assert PulseCodec.set_led_brightness(50, body_light=False) == bytes([0xAA, 0x8A, 0x03, 50, 0, 1])
|
|
18
|
+
|
|
19
|
+
def test_set_led_brightness_projection_off(self):
|
|
20
|
+
assert PulseCodec.set_led_brightness(50, projection=False) == bytes([0xAA, 0x8A, 0x03, 50, 1, 0])
|
|
21
|
+
|
|
22
|
+
def test_request_speaker_info(self):
|
|
23
|
+
assert PulseCodec.request_speaker_info() == bytes([0xAA, 0x11, 0x00])
|
|
24
|
+
|
|
25
|
+
def test_request_led_brightness(self):
|
|
26
|
+
assert PulseCodec.request_led_brightness() == bytes([0xAA, 0x8B, 0x00])
|
|
27
|
+
|
|
28
|
+
def test_request_led_package_info(self):
|
|
29
|
+
assert PulseCodec.request_led_package_info() == bytes([0xAA, 0x83, 0x00])
|
|
30
|
+
|
|
31
|
+
def test_switch_package(self):
|
|
32
|
+
assert PulseCodec.switch_package(0x02) == bytes([0xAA, 0x90, 0x01, 0x02])
|
|
33
|
+
|
|
34
|
+
def test_set_led_package_static_color(self):
|
|
35
|
+
result = PulseCodec.set_led_package(
|
|
36
|
+
package_id=0xC1, active_patterns=[], all_patterns=[],
|
|
37
|
+
color_effect=0, red=255, green=0, blue=0,
|
|
38
|
+
)
|
|
39
|
+
assert result == bytes([0xAA, 0x85, 0x07, 0xC1, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00])
|
|
40
|
+
|
|
41
|
+
def test_set_led_package_with_patterns(self):
|
|
42
|
+
result = PulseCodec.set_led_package(
|
|
43
|
+
package_id=0x01, active_patterns=[0x01, 0x02], all_patterns=[0x01, 0x02, 0x03, 0x04],
|
|
44
|
+
color_effect=1, red=0xFF, green=0xFF, blue=0xFF,
|
|
45
|
+
)
|
|
46
|
+
assert result == bytes([
|
|
47
|
+
0xAA, 0x85, 0x0B, 0x01, 0x02, 0x04,
|
|
48
|
+
0x01, 0x02, 0x03, 0x04,
|
|
49
|
+
0x01, 0xFF, 0xFF, 0xFF,
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestDecoding:
|
|
54
|
+
def test_parse_valid(self):
|
|
55
|
+
resp = PulseCodec.parse(bytes([0xAA, 0x72, 0x01, 0x01]))
|
|
56
|
+
assert resp is not None
|
|
57
|
+
assert resp.command_id == 0x72
|
|
58
|
+
assert resp.payload == bytes([0x01])
|
|
59
|
+
|
|
60
|
+
def test_parse_bad_header(self):
|
|
61
|
+
assert PulseCodec.parse(bytes([0xBB, 0x72, 0x01])) is None
|
|
62
|
+
|
|
63
|
+
def test_parse_too_short(self):
|
|
64
|
+
assert PulseCodec.parse(bytes([0xAA, 0x72])) is None
|
|
65
|
+
|
|
66
|
+
def test_parse_empty_payload(self):
|
|
67
|
+
resp = PulseCodec.parse(bytes([0xAA, 0x11, 0x00]))
|
|
68
|
+
assert resp is not None
|
|
69
|
+
assert resp.payload == b""
|
|
70
|
+
|
|
71
|
+
def test_parse_brightness_state(self):
|
|
72
|
+
brt = PulseCodec.parse_brightness_state(bytes([0xAA, 0x8C, 0x03, 50, 1, 1]))
|
|
73
|
+
assert brt == BrightnessState(level=50, body_light_on=True, projection_on=True)
|
|
74
|
+
|
|
75
|
+
def test_parse_brightness_state_lights_off(self):
|
|
76
|
+
brt = PulseCodec.parse_brightness_state(bytes([0xAA, 0x8C, 0x03, 30, 0, 0]))
|
|
77
|
+
assert brt == BrightnessState(level=30, body_light_on=False, projection_on=False)
|
|
78
|
+
|
|
79
|
+
def test_parse_brightness_wrong_cmd(self):
|
|
80
|
+
assert PulseCodec.parse_brightness_state(bytes([0xAA, 0x72, 0x03, 50, 1, 1])) is None
|
|
81
|
+
|
|
82
|
+
def test_parse_brightness_short_payload(self):
|
|
83
|
+
assert PulseCodec.parse_brightness_state(bytes([0xAA, 0x8C, 0x02, 50, 1])) is None
|
|
84
|
+
|
|
85
|
+
def test_parse_selected_theme_party(self):
|
|
86
|
+
assert PulseCodec.parse_selected_theme(bytes([0xAA, 0x84, 0x02, 0x00, 0x02])) == LEDTheme.PARTY
|
|
87
|
+
|
|
88
|
+
def test_parse_selected_theme_nature(self):
|
|
89
|
+
assert PulseCodec.parse_selected_theme(bytes([0xAA, 0x84, 0x02, 0x00, 0x01])) == LEDTheme.NATURE
|
|
90
|
+
|
|
91
|
+
def test_parse_selected_theme_wrong_cmd(self):
|
|
92
|
+
assert PulseCodec.parse_selected_theme(bytes([0xAA, 0x72, 0x02, 0x00, 0x02])) is None
|
|
93
|
+
|
|
94
|
+
def test_parse_selected_theme_short_payload(self):
|
|
95
|
+
assert PulseCodec.parse_selected_theme(bytes([0xAA, 0x84, 0x01, 0x00])) is None
|
|
96
|
+
|
|
97
|
+
def test_parse_selected_theme_unknown_value(self):
|
|
98
|
+
assert PulseCodec.parse_selected_theme(bytes([0xAA, 0x84, 0x02, 0x00, 0xFF])) is None
|
|
99
|
+
|
|
100
|
+
def test_parse_bytearray(self):
|
|
101
|
+
resp = PulseCodec.parse(bytearray([0xAA, 0x72, 0x01, 0x01]))
|
|
102
|
+
assert resp is not None
|
|
103
|
+
assert resp.command_id == 0x72
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from pulse5 import config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestConfig:
|
|
7
|
+
def test_save_and_load(self, tmp_path, monkeypatch):
|
|
8
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
9
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
|
|
10
|
+
|
|
11
|
+
config.save_device("AA:BB:CC:DD:EE:FF", "JBL Pulse 5")
|
|
12
|
+
result = config.get_saved_device()
|
|
13
|
+
assert result == ("AA:BB:CC:DD:EE:FF", "JBL Pulse 5")
|
|
14
|
+
|
|
15
|
+
def test_no_saved_device(self, tmp_path, monkeypatch):
|
|
16
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
17
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
|
|
18
|
+
|
|
19
|
+
assert config.get_saved_device() is None
|
|
20
|
+
|
|
21
|
+
def test_clear_device(self, tmp_path, monkeypatch):
|
|
22
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
23
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
|
|
24
|
+
|
|
25
|
+
config.save_device("AA:BB:CC:DD:EE:FF", "JBL Pulse 5")
|
|
26
|
+
config.clear_device()
|
|
27
|
+
assert config.get_saved_device() is None
|
|
28
|
+
|
|
29
|
+
def test_corrupt_config(self, tmp_path, monkeypatch):
|
|
30
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
31
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
|
|
32
|
+
|
|
33
|
+
(tmp_path / "config.json").write_text("not json{{{")
|
|
34
|
+
assert config.get_saved_device() is None
|
|
35
|
+
|
|
36
|
+
def test_missing_name_defaults(self, tmp_path, monkeypatch):
|
|
37
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
38
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
|
|
39
|
+
|
|
40
|
+
(tmp_path / "config.json").write_text(json.dumps({"address": "AA:BB"}))
|
|
41
|
+
result = config.get_saved_device()
|
|
42
|
+
assert result == ("AA:BB", "Unknown")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from pulse5.protocol.models import (
|
|
2
|
+
PATTERN_BY_NAME,
|
|
3
|
+
THEME_BY_NAME,
|
|
4
|
+
ColorEffect,
|
|
5
|
+
LEDPattern,
|
|
6
|
+
LEDTheme,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestLEDTheme:
|
|
11
|
+
def test_values(self):
|
|
12
|
+
assert LEDTheme.NATURE == 0x01
|
|
13
|
+
assert LEDTheme.PARTY == 0x02
|
|
14
|
+
assert LEDTheme.SPIRITUAL == 0x03
|
|
15
|
+
assert LEDTheme.COCKTAIL == 0x04
|
|
16
|
+
assert LEDTheme.WEATHER == 0x05
|
|
17
|
+
assert LEDTheme.CANVAS == 0xC1
|
|
18
|
+
|
|
19
|
+
def test_display_names(self):
|
|
20
|
+
assert LEDTheme.NATURE.display_name == "Nature"
|
|
21
|
+
assert LEDTheme.CANVAS.display_name == "Canvas"
|
|
22
|
+
|
|
23
|
+
def test_nature_patterns(self):
|
|
24
|
+
patterns = LEDTheme.NATURE.patterns
|
|
25
|
+
assert len(patterns) == 4
|
|
26
|
+
assert LEDPattern.CAMPFIRE in patterns
|
|
27
|
+
assert LEDPattern.NORTHERN_LIGHTS in patterns
|
|
28
|
+
assert LEDPattern.SEA_WAVE in patterns
|
|
29
|
+
assert LEDPattern.UNIVERSE in patterns
|
|
30
|
+
|
|
31
|
+
def test_party_patterns(self):
|
|
32
|
+
assert len(LEDTheme.PARTY.patterns) == 5
|
|
33
|
+
assert LEDPattern.RAINBOW in LEDTheme.PARTY.patterns
|
|
34
|
+
|
|
35
|
+
def test_spiritual_patterns(self):
|
|
36
|
+
assert len(LEDTheme.SPIRITUAL.patterns) == 3
|
|
37
|
+
|
|
38
|
+
def test_cocktail_patterns(self):
|
|
39
|
+
assert len(LEDTheme.COCKTAIL.patterns) == 4
|
|
40
|
+
|
|
41
|
+
def test_weather_patterns(self):
|
|
42
|
+
assert len(LEDTheme.WEATHER.patterns) == 5
|
|
43
|
+
|
|
44
|
+
def test_canvas_empty(self):
|
|
45
|
+
assert LEDTheme.CANVAS.patterns == []
|
|
46
|
+
|
|
47
|
+
def test_all_patterns_assigned(self):
|
|
48
|
+
all_patterns = []
|
|
49
|
+
for theme in LEDTheme:
|
|
50
|
+
all_patterns.extend(theme.patterns)
|
|
51
|
+
assert len(all_patterns) == len(LEDPattern)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestLEDPattern:
|
|
55
|
+
def test_campfire_value(self):
|
|
56
|
+
assert LEDPattern.CAMPFIRE == 0x01
|
|
57
|
+
|
|
58
|
+
def test_cherry_value(self):
|
|
59
|
+
assert LEDPattern.CHERRY == 0x16
|
|
60
|
+
|
|
61
|
+
def test_display_name(self):
|
|
62
|
+
assert LEDPattern.NORTHERN_LIGHTS.display_name == "Northern Lights"
|
|
63
|
+
assert LEDPattern.FRUIT_GIN.display_name == "Fruit Gin"
|
|
64
|
+
|
|
65
|
+
def test_theme_mapping(self):
|
|
66
|
+
assert LEDPattern.CAMPFIRE.theme == LEDTheme.NATURE
|
|
67
|
+
assert LEDPattern.RAINBOW.theme == LEDTheme.PARTY
|
|
68
|
+
assert LEDPattern.LAVA.theme == LEDTheme.SPIRITUAL
|
|
69
|
+
assert LEDPattern.MOJITO.theme == LEDTheme.COCKTAIL
|
|
70
|
+
assert LEDPattern.THUNDER.theme == LEDTheme.WEATHER
|
|
71
|
+
|
|
72
|
+
def test_all_patterns_have_theme(self):
|
|
73
|
+
for pattern in LEDPattern:
|
|
74
|
+
assert pattern.theme is not None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestColorEffect:
|
|
78
|
+
def test_values(self):
|
|
79
|
+
assert ColorEffect.STATIC == 0
|
|
80
|
+
assert ColorEffect.COLOR_LOOP == 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestLookups:
|
|
84
|
+
def test_theme_by_name(self):
|
|
85
|
+
assert THEME_BY_NAME["nature"] == LEDTheme.NATURE
|
|
86
|
+
assert THEME_BY_NAME["party"] == LEDTheme.PARTY
|
|
87
|
+
assert "canvas" not in THEME_BY_NAME
|
|
88
|
+
|
|
89
|
+
def test_pattern_by_name(self):
|
|
90
|
+
assert PATTERN_BY_NAME["campfire"] == LEDPattern.CAMPFIRE
|
|
91
|
+
assert PATTERN_BY_NAME["northern lights"] == LEDPattern.NORTHERN_LIGHTS
|
|
92
|
+
|
|
93
|
+
def test_all_themes_in_lookup(self):
|
|
94
|
+
# Canvas excluded
|
|
95
|
+
assert len(THEME_BY_NAME) == 5
|
|
96
|
+
|
|
97
|
+
def test_all_patterns_in_lookup(self):
|
|
98
|
+
assert len(PATTERN_BY_NAME) == len(LEDPattern)
|