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.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse5ctl
3
+ Version: 1.3.0
4
+ Summary: CLI & MCP server for JBL Pulse 5 LED control over BLE
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: bleak>=0.21.0
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: mcp>=1.2.0
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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse5ctl
3
+ Version: 1.3.0
4
+ Summary: CLI & MCP server for JBL Pulse 5 LED control over BLE
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: bleak>=0.21.0
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: mcp>=1.2.0
@@ -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,3 @@
1
+ [console_scripts]
2
+ pulse5 = pulse5.cli:main
3
+ pulse5-mcp = pulse5.mcp_server:main
@@ -0,0 +1,3 @@
1
+ bleak>=0.21.0
2
+ click>=8.0
3
+ mcp>=1.2.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)