pulse5ctl 1.3.0__py3-none-any.whl

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.
pulse5/__init__.py ADDED
File without changes
pulse5/ble.py ADDED
@@ -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)
pulse5/cli.py ADDED
@@ -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()
pulse5/config.py ADDED
@@ -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)
pulse5/mcp_server.py ADDED
@@ -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,14 @@
1
+ pulse5/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pulse5/ble.py,sha256=mrzRYnPxgwbwa0be7iTwapEiFeo0ldOVc0GF9nryml8,4576
3
+ pulse5/cli.py,sha256=Wu2H6c3g1u7WMlvRAcij9UT5cNx8M-E7QKEGmMAjDtM,4748
4
+ pulse5/config.py,sha256=4L43kgetpn9-3pSHSzcnLfJzJy50QZtgyh-jjNjB-3c,975
5
+ pulse5/mcp_server.py,sha256=mQ25VIN48s4PhPGyrzoiPV2Vci21ZOU-QE8jP01uF6I,1767
6
+ pulse5/protocol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pulse5/protocol/codec.py,sha256=sUxj-2eOg5dff2SgjykDDxwuF65ofxoeTZptN1gQQHM,3161
8
+ pulse5/protocol/constants.py,sha256=V1sjUnfb6nWh_1cOrR5SEuQQ9kTmDjM3-VoSYHgD2pM,639
9
+ pulse5/protocol/models.py,sha256=VhxbBVcaTyPFsW1xs1_BhYbmAa8gf0DfEg72tQN7W9I,3244
10
+ pulse5ctl-1.3.0.dist-info/METADATA,sha256=Bw_I0f3RH70LBH0_zQRrltVlyS9h5DlB0s5yeu4dYhE,245
11
+ pulse5ctl-1.3.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
12
+ pulse5ctl-1.3.0.dist-info/entry_points.txt,sha256=U_wLDZvVUsrOyVHqKQSMR6Hl_c1ivmPoDIFlXjxP4Ps,79
13
+ pulse5ctl-1.3.0.dist-info/top_level.txt,sha256=a9CWBETAUNB6nQBWtQbs2cPi-_oF9WuJGo9W69ctXiQ,7
14
+ pulse5ctl-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pulse5 = pulse5.cli:main
3
+ pulse5-mcp = pulse5.mcp_server:main
@@ -0,0 +1 @@
1
+ pulse5