crestron-swamp-controller 1.0.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.
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: crestron-swamp-controller
3
+ Version: 1.0.0
4
+ Summary: Interactive controller for Crestron SWAMP media amplifier systems
5
+ Author-email: jaroy <noreply@github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jaroy/swamp-controller
8
+ Project-URL: Documentation, https://github.com/jaroy/swamp-controller#readme
9
+ Project-URL: Repository, https://github.com/jaroy/swamp-controller
10
+ Project-URL: Issues, https://github.com/jaroy/swamp-controller/issues
11
+ Keywords: crestron,swamp,home-assistant,audio,multi-zone
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Home Automation
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pyyaml>=6.0.1
21
+ Requires-Dist: prompt-toolkit>=3.0.43
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.4.3; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # SWAMP Controller
28
+
29
+ Interactive command-line controller for Crestron SWAMP media amplifier systems.
30
+
31
+ ## Features
32
+
33
+ - **Interactive Shell**: User-friendly command prompt with autocomplete
34
+ - **TCP Server**: Listens for connections from SWAMP devices
35
+ - **State Management**: Maintains local state synchronized with device
36
+ - **Multi-zone Support**: Commands automatically broadcast to all zones in a target
37
+ - **Pluggable Protocol**: Protocol handler designed for easy extension
38
+ - **Home Assistant Integration**: Native HA integration with media player entities
39
+
40
+ ## Home Assistant Integration
41
+
42
+ This project includes a **Home Assistant integration** that exposes your Crestron SWAMP system as media player entities in Home Assistant. Each target (room/zone) appears as a controllable media player with volume control, source selection, and power control.
43
+
44
+ ### Installation via HACS (Recommended)
45
+
46
+ [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/hacs/integration)
47
+ [![PyPI](https://img.shields.io/pypi/v/crestron-swamp-controller)](https://pypi.org/project/crestron-swamp-controller/)
48
+
49
+ 1. Ensure [HACS](https://hacs.xyz/) is installed in your Home Assistant instance
50
+ 2. Add this repository as a custom repository in HACS:
51
+ - Open HACS in Home Assistant
52
+ - Go to "Integrations"
53
+ - Click the three dots menu (top right) and select "Custom repositories"
54
+ - Add `https://github.com/jaroy/swamp-controller` as an Integration
55
+ - Click "Add"
56
+ 3. Find "Crestron SWAMP Controller" in HACS and click "Download"
57
+ 4. Restart Home Assistant
58
+ 5. Add the integration through Settings > Devices & Services > Add Integration
59
+
60
+ The `crestron-swamp-controller` package will be automatically installed from PyPI when you add the integration.
61
+
62
+ See [HOMEASSISTANT.md](HOMEASSISTANT.md) for more detailed installation instructions and configuration options.
63
+
64
+ ### Features
65
+ - Media player entity for each target/zone
66
+ - Real-time state updates from SWAMP device
67
+ - Volume control (0-100%)
68
+ - Source selection from configured sources
69
+ - Power on/off control
70
+ - Device availability tracking
71
+
72
+ ---
73
+
74
+ ## CLI Installation
75
+
76
+ 1. Ensure Python 3.12+ is installed
77
+ 2. Create and activate virtual environment:
78
+ ```bash
79
+ python -m venv .venv
80
+ source .venv/bin/activate # On Linux/Mac
81
+ # or
82
+ .venv\Scripts\activate # On Windows
83
+ ```
84
+ 3. Install dependencies:
85
+ ```bash
86
+ pip install -r requirements.txt
87
+ ```
88
+
89
+ ## Configuration
90
+
91
+ Edit `config/config.yaml` to define your audio sources and target zones:
92
+
93
+ ```yaml
94
+ sources:
95
+ - id: music-a
96
+ name: Player A
97
+ swamp-source-id: 4
98
+
99
+ targets:
100
+ - id: office-terrace
101
+ name: Office Terrace
102
+ swamp-zones:
103
+ - unit: 3
104
+ zone: 1
105
+ ```
106
+
107
+ ### On the SWAMP
108
+ We have to tell the SWAMP to connect to us instead of a Crestron processor.
109
+ Use these TELNET commands:
110
+ ```
111
+ adds 51 <IP address> 4 <port>
112
+ reboot
113
+ ```
114
+
115
+ Note that you can see the currently configured controller with the `ipt` command:
116
+ ```
117
+ $ telnet 192.168.1.89
118
+ Trying 192.168.1.89...
119
+ Connected to 192.168.1.89.
120
+ Escape character is '^]'.
121
+
122
+ SWAMP Control Console
123
+ Connected to Host: SWAMP-00107FE7C4CD
124
+
125
+ SWAMP>ipt
126
+ CIP_ID Type Status DevID Port IP Address/SiteName
127
+ 51 GWAY ONLINE 4 41794 010.194.005.251
128
+ ```
129
+
130
+ ## Usage
131
+
132
+ Start the controller:
133
+ ```bash
134
+ python -m swamp
135
+ ```
136
+
137
+ Or with custom port:
138
+ ```bash
139
+ python -m swamp --port 41794
140
+ ```
141
+
142
+ Or with custom config:
143
+ ```bash
144
+ python -m swamp --config /path/to/config.yaml
145
+ ```
146
+
147
+ ## Available Commands
148
+
149
+ Once the shell is running, you can use these commands:
150
+
151
+ ### Route audio source to target
152
+ ```
153
+ route <source-id> <target-id>
154
+ ```
155
+ Example: `route music-a office`
156
+
157
+ ### Set volume
158
+ ```
159
+ volume <target-id> <level>
160
+ ```
161
+ Example: `volume office 75`
162
+
163
+ ### Adjust volume relatively
164
+ ```
165
+ volume <target-id> +/-<delta>
166
+ ```
167
+ Example: `volume office +10` or `volume office -5`
168
+
169
+ ### Power control
170
+ ```
171
+ power <target-id> on <source-id>
172
+ power <target-id> off
173
+ ```
174
+ Power on requires a source (zones need a source to be "on"). Power off sets the source to 0.
175
+
176
+ Examples:
177
+ - `power office on music-a` - Power on office with music-a as source
178
+ - `power office off` - Power off office
179
+
180
+ ### Show status
181
+ ```
182
+ status [target-id]
183
+ ```
184
+ Example: `status office` or `status` (shows all)
185
+
186
+ ### Send WHOIS request
187
+ ```
188
+ whois
189
+ ```
190
+ Sends a WHOIS request (0f 00 01 02) to the connected SWAMP device. This is automatically sent when a device connects.
191
+
192
+ ### List sources or targets
193
+ ```
194
+ list sources
195
+ list targets
196
+ ```
197
+
198
+ ### Get help
199
+ ```
200
+ help
201
+ ```
202
+
203
+ ### Exit
204
+ ```
205
+ quit
206
+ ```
207
+
208
+ ## Architecture
209
+ The controller works by impersonating a Crestron processor gateway, e.g. a Crestron CP3. The client
210
+ is the SWAMP and it connects to our server which then establishes the communication link.
211
+
212
+ ```
213
+ User Shell (REPL)
214
+ ↓
215
+ Controller (orchestration)
216
+ ↓
217
+ State Manager (zone/source mapping)
218
+ ↓
219
+ Protocol Handler (pluggable)
220
+ ↓
221
+ TCP Server (asyncio)
222
+ ```
223
+
224
+ ### Components
225
+
226
+ - **Models**: Data classes for configuration and state
227
+ - **Core**: Configuration loading, state management, orchestration
228
+ - **Protocol**: Abstract protocol handler with stub implementation
229
+ - **Network**: Asyncio TCP server for SWAMP device connections
230
+ - **Shell**: Command parser and interactive REPL
231
+
232
+ ## Protocol Implementation
233
+ The Protocol Handler partially implements the Crestron Internet Protocol (CIP), a proprietary protocol
234
+ for communication between Crestron devices. Only the message types for basic control of the SWAMP system
235
+ are implemented.
236
+
237
+ ### Message Format
238
+ All CIP messages follow this format:
239
+ - **Byte 0**: Message type
240
+ - **Bytes 1-2**: Remaining length (total bytes - 3) in big-endian
241
+ - **Bytes 3+**: Payload
242
+
243
+ Example: `0a 00 0a 00 51 a3 42 40 02 00 00 00 00`
244
+ - Type: `0x0a` (CLIENT_SIGNON)
245
+ - Length: `0x00 0x0a` = 10 bytes remaining
246
+ - Payload: `00 51 a3 42 40 02 00 00 00 00` (10 bytes)
247
+ - Total: 1 + 2 + 10 = 13 bytes
248
+
249
+ ### Implemented Messages
250
+ - **WHOIS** (`0f 00 01 02`) - Sent automatically when client connects, also available via `whois` command
251
+ - **PING** (`0d 00 02 00 00`) - Automatically detected and triggers PONG response. Sent periodically in the background.
252
+ - **PONG** (`0e 00 02 00 00`) - Sent automatically in response to PING
253
+ - **CLIENT_SIGNON** (`0a ...`) - Sent by device on connect, triggers CONN_ACCEPTED response
254
+ - **CONN_ACCEPTED** (`02 00 04 00 00 00 03`) - Sent automatically in response to CLIENT_SIGNON
255
+ - **JOIN** (`05 ...`) - The actual control messages for setting and getting information about the SWAMP
256
+
257
+ ### Unknown Message Handling
258
+ Any message not recognized will be printed to the console in hex format, making it easy to discover and implement new message types.
259
+
260
+ Example output:
261
+ ```
262
+ Unknown message type ff (4 bytes): ff aa bb cc
263
+ Recognized but unimplemented message type 0a (13 bytes): 0a 00 0a 00 51 a3 42 40 02 00 00 00 00
264
+ ```
265
+
266
+ ### Adding New Message Types
267
+ To add support for a new message type, edit `swamp/protocol/swamp_protocol.py`:
268
+
269
+ 1. Add the message type to `decode_message()` dispatcher
270
+ 2. Create a `_decode_message_type_XX()` method
271
+ 3. Implement encoding methods if needed:
272
+ - `encode_route_command()` - Route audio source to zone
273
+ - `encode_volume_command()` - Set zone volume
274
+ - `encode_power_command()` - Control zone power
275
+
276
+ ## Development
277
+
278
+ Run tests:
279
+ ```bash
280
+ pytest
281
+ ```
282
+
283
+ The test suite uses dynamic port allocation (via `get_free_port()`) to avoid conflicts with running instances of the controller.
284
+
285
+ Enable debug logging:
286
+ ```bash
287
+ python -m swamp --log-level DEBUG
288
+ ```
289
+
290
+ Run on a different port:
291
+ ```bash
292
+ python -m swamp --port 41795
293
+ ```
294
+
295
+ ## License
296
+
297
+ Copyright © 2025
@@ -0,0 +1,25 @@
1
+ crestron_swamp_controller-1.0.0.dist-info/licenses/LICENSE,sha256=dMSjtMcKJMdiIDWu6JE39B2ax1nqNQOE0bCUtLM2Uq0,1062
2
+ swamp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ swamp/__main__.py,sha256=ZK3CudlshUda0T94nuYlhUkuRQ6Lmh6LG5e8XDxsgzE,4248
4
+ swamp/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ swamp/core/config_manager.py,sha256=I-LJq750SVeaeOB9h17zFXVZUtPGJVX5Xz4fmJAm6Qk,942
6
+ swamp/core/controller.py,sha256=m2TIEFHpmBhdhPo8X4NGhOgvoIPbVQ9pO4suM4e0oW4,4678
7
+ swamp/core/state_manager.py,sha256=DftNTNNt3EkWgKO5zuolaxaEfKPmKgF_vPz2tSxN6hE,3201
8
+ swamp/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ swamp/models/commands.py,sha256=xjDfHzP98yy0U3CKzYmsxD5h1Qz9QbN0pFv7kGLbRZ8,379
10
+ swamp/models/config.py,sha256=OABJCUFC_j9ejPRrgjsybNA68V1d_qjFBcPkXONsRE4,499
11
+ swamp/models/state.py,sha256=V-a-j_7kYBJYSgq6C0tnkahasU5sWehHEVtdQnNEsos,1222
12
+ swamp/network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ swamp/network/tcp_server.py,sha256=uIbhArXCbXPc7sNLTG1Npyk4Ws70JYwud9nw41hs-N4,10413
14
+ swamp/protocol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ swamp/protocol/base.py,sha256=x7GQWPlX-d2FkgS2q2m1VJ2gvw-e55DRmhF9iTqs6Jg,1295
16
+ swamp/protocol/swamp_protocol.py,sha256=JtBEdfXO9rG3SCaTDNCz2rrp1faeGMZtt3DxiWHzCOw,11150
17
+ swamp/shell/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ swamp/shell/commands.py,sha256=Wic5xjXQUW7q6aYjSiRRW9Rj3l-9O318Le-18FyuQY4,7578
19
+ swamp/shell/parser.py,sha256=O6iZYyl9EG375eldcNyO2q7c4Yk__qVBPNQCG35VMCk,935
20
+ swamp/shell/repl.py,sha256=d2z3WSRqKE2C0TXyiRaOy4cY9hbI1kiJKDOqJ8OBAu4,1731
21
+ crestron_swamp_controller-1.0.0.dist-info/METADATA,sha256=bqpOSP2KaFAFpU6ndqeYLQ7NiqlO0ZnU0pTfeq1T8pw,8605
22
+ crestron_swamp_controller-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
+ crestron_swamp_controller-1.0.0.dist-info/entry_points.txt,sha256=DcckC32qrpdJDe0nq8lovn-aTtRfCthhaZo1ET_AkdA,46
24
+ crestron_swamp_controller-1.0.0.dist-info/top_level.txt,sha256=buHlyr_xSlQ015_eeHPs4NDqmjU_HhEiLCevIdeTAW0,6
25
+ crestron_swamp_controller-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ swamp = swamp.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 jaroy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
swamp/__init__.py ADDED
File without changes
swamp/__main__.py ADDED
@@ -0,0 +1,120 @@
1
+ import argparse
2
+ import asyncio
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from swamp.core.config_manager import ConfigManager
7
+ from swamp.core.state_manager import StateManager
8
+ from swamp.core.controller import SwampController
9
+ from swamp.protocol.swamp_protocol import SwampProtocol
10
+ from swamp.network.tcp_server import SwampTcpServer
11
+ from swamp.shell.parser import CommandParser
12
+ from swamp.shell.commands import CommandHandlers
13
+ from swamp.shell.repl import InteractiveShell
14
+
15
+
16
+ def setup_logging(level: str):
17
+ """Setup logging configuration"""
18
+ numeric_level = getattr(logging, level.upper(), None)
19
+ if not isinstance(numeric_level, int):
20
+ raise ValueError(f'Invalid log level: {level}')
21
+
22
+ logging.basicConfig(
23
+ level=numeric_level,
24
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
25
+ datefmt='%Y-%m-%d %H:%M:%S'
26
+ )
27
+
28
+
29
+ async def main_async():
30
+ """Main async entry point"""
31
+ parser = argparse.ArgumentParser(description='SWAMP Media Controller')
32
+ parser.add_argument('--port', type=int, default=41794,
33
+ help='TCP port to listen on for SWAMP device (default: 41794)')
34
+ parser.add_argument('--config', type=Path,
35
+ default=Path('config/config.yaml'),
36
+ help='Path to configuration file (default: config/config.yaml)')
37
+ parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
38
+ default='INFO', help='Logging level (default: INFO)')
39
+
40
+ args = parser.parse_args()
41
+
42
+ setup_logging(args.log_level)
43
+ logger = logging.getLogger(__name__)
44
+
45
+ logger.info('Starting SWAMP Controller')
46
+
47
+ try:
48
+ config = ConfigManager.load(args.config)
49
+ logger.info(f'Loaded config: {len(config.sources)} sources, {len(config.targets)} targets')
50
+ except Exception as e:
51
+ logger.error(f'Failed to load config: {e}')
52
+ return 1
53
+
54
+ protocol = SwampProtocol()
55
+ state_manager = StateManager(config)
56
+ tcp_server = SwampTcpServer(args.port, protocol, state_manager)
57
+ controller = SwampController(config, tcp_server, state_manager)
58
+
59
+ cmd_parser = CommandParser()
60
+ handlers = CommandHandlers(controller)
61
+
62
+ cmd_parser.register('route', handlers.cmd_route)
63
+ cmd_parser.register('volume', handlers.cmd_volume)
64
+ cmd_parser.register('power', handlers.cmd_power)
65
+ cmd_parser.register('status', handlers.cmd_status)
66
+ cmd_parser.register('whois', handlers.cmd_whois)
67
+ cmd_parser.register('list', handlers.cmd_list)
68
+ cmd_parser.register('help', handlers.cmd_help)
69
+
70
+ shell = InteractiveShell(cmd_parser, handlers)
71
+
72
+ print(f"SWAMP Controller v0.1.0")
73
+ print(f"Listening for SWAMP device on port {args.port}")
74
+ print(f"Type 'help' for available commands\n")
75
+
76
+ server_task = asyncio.create_task(tcp_server.start())
77
+
78
+ try:
79
+ await shell.run()
80
+ except KeyboardInterrupt:
81
+ logger.info('Interrupted by user')
82
+ finally:
83
+ logger.info('Shutting down')
84
+ # Close any active client connections first
85
+ if tcp_server.client_writer and not tcp_server.client_writer.is_closing():
86
+ try:
87
+ tcp_server.client_writer.close()
88
+ await asyncio.wait_for(
89
+ tcp_server.client_writer.wait_closed(),
90
+ timeout=1.0
91
+ )
92
+ except asyncio.TimeoutError:
93
+ logger.warning('Timeout waiting for client connection to close')
94
+ except Exception as e:
95
+ logger.debug(f'Error closing client connection: {e}')
96
+
97
+ # Cancel server task (the async with context will close the server)
98
+ server_task.cancel()
99
+ try:
100
+ await asyncio.wait_for(server_task, timeout=2.0)
101
+ except asyncio.CancelledError:
102
+ logger.debug('Server task cancelled successfully')
103
+ except asyncio.TimeoutError:
104
+ logger.warning('Timeout waiting for server to close')
105
+ except Exception as e:
106
+ logger.debug(f'Error during server shutdown: {e}')
107
+
108
+ return 0
109
+
110
+
111
+ def main():
112
+ """Main entry point"""
113
+ try:
114
+ return asyncio.run(main_async())
115
+ except KeyboardInterrupt:
116
+ return 0
117
+
118
+
119
+ if __name__ == '__main__':
120
+ exit(main())
swamp/core/__init__.py ADDED
File without changes
@@ -0,0 +1,36 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from ..models.config import AppConfig, Source, Target, SwampZone
4
+
5
+
6
+ class ConfigManager:
7
+ """Loads and validates configuration"""
8
+
9
+ @staticmethod
10
+ def load(config_path: Path) -> AppConfig:
11
+ """Load config from YAML file"""
12
+ with open(config_path) as f:
13
+ data = yaml.safe_load(f)
14
+
15
+ sources = [
16
+ Source(
17
+ id=s['id'],
18
+ name=s['name'],
19
+ swamp_source_id=s['swamp-source-id']
20
+ )
21
+ for s in data['sources']
22
+ ]
23
+
24
+ targets = [
25
+ Target(
26
+ id=t['id'],
27
+ name=t['name'],
28
+ swamp_zones=[
29
+ SwampZone(unit=z['unit'], zone=z['zone'])
30
+ for z in t['swamp-zones']
31
+ ]
32
+ )
33
+ for t in data['targets']
34
+ ]
35
+
36
+ return AppConfig(sources=sources, targets=targets)
@@ -0,0 +1,120 @@
1
+ import logging
2
+ from ..models.config import AppConfig
3
+
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class SwampController:
9
+ """Main coordinator - orchestrates all layers"""
10
+
11
+ def __init__(self, config: AppConfig, tcp_server, state_manager):
12
+ self.config = config
13
+ self.tcp = tcp_server
14
+ self.state = state_manager
15
+
16
+ async def route_source_to_target(self, source_id: str, target_id: str) -> None:
17
+ """High-level routing command"""
18
+ source = self.state.get_source_by_id(source_id)
19
+ zones = self.state.get_zones_for_target(target_id)
20
+
21
+ logger.info(f"Routing {source.name} to {target_id} ({len(zones)} zones)")
22
+
23
+ for zone_state in zones:
24
+ command_bytes = await self.tcp.protocol.encode_route_command(
25
+ zone_state.unit, zone_state.zone, source.swamp_source_id
26
+ )
27
+ await self.tcp.send_command(command_bytes)
28
+
29
+ zone_state.source_id = source.swamp_source_id
30
+
31
+ async def set_volume(self, target_id: str, level: int) -> None:
32
+ """Set volume for target"""
33
+ zones = self.state.get_zones_for_target(target_id)
34
+
35
+ logger.info(f"Setting {target_id} volume to {level} ({len(zones)} zones)")
36
+
37
+ for zone_state in zones:
38
+ command_bytes = await self.tcp.protocol.encode_volume_command(
39
+ zone_state.unit, zone_state.zone, level
40
+ )
41
+ await self.tcp.send_command(command_bytes)
42
+
43
+ zone_state.volume = level
44
+
45
+ async def set_power(self, target_id: str, power_on: bool, source_id: str | None = None) -> None:
46
+ """Set power for target (really just routes source to zone)
47
+
48
+ Power on requires a source_id. Power off sets source to 0.
49
+ """
50
+ zones = self.state.get_zones_for_target(target_id)
51
+
52
+ if power_on:
53
+ if not source_id:
54
+ raise ValueError("Power on requires a source_id")
55
+ # Power on = route source to zone
56
+ source = self.state.get_source_by_id(source_id)
57
+ swamp_source_id = source.swamp_source_id
58
+ logger.info(f"Powering on {target_id} with source {source_id} ({len(zones)} zones)")
59
+
60
+ for zone_state in zones:
61
+ # Use route command for power on (sets the actual source)
62
+ command_bytes = await self.tcp.protocol.encode_route_command(
63
+ zone_state.unit, zone_state.zone, swamp_source_id
64
+ )
65
+ await self.tcp.send_command(command_bytes)
66
+ zone_state.power = True
67
+ zone_state.source_id = swamp_source_id
68
+ else:
69
+ # Power off = route source 0 (no source) to zone
70
+ logger.info(f"Powering off {target_id} ({len(zones)} zones)")
71
+
72
+ for zone_state in zones:
73
+ command_bytes = await self.tcp.protocol.encode_power_command(
74
+ zone_state.unit, zone_state.zone, False
75
+ )
76
+ await self.tcp.send_command(command_bytes)
77
+ zone_state.power = False
78
+ zone_state.source_id = None
79
+
80
+ async def send_whois(self) -> None:
81
+ """Send WHOIS request to connected device"""
82
+ logger.info("Sending WHOIS request")
83
+ whois_bytes = await self.tcp.protocol.encode_whois()
84
+ await self.tcp.send_command(whois_bytes)
85
+
86
+ async def get_status(self) -> dict:
87
+ """Get current system status"""
88
+ state = self.state.state
89
+
90
+ # Calculate time since last message
91
+ time_since_last = None
92
+ if state.last_message_received:
93
+ from datetime import datetime
94
+ time_since_last = (datetime.now() - state.last_message_received).total_seconds()
95
+
96
+ return {
97
+ 'connected': state.connected,
98
+ 'socket_connected': state.socket_connected,
99
+ 'conn_accepted_sent': state.conn_accepted_sent,
100
+ 'client_address': state.client_address,
101
+ 'last_message_seconds': time_since_last,
102
+ 'targets': [
103
+ {
104
+ 'id': target.id,
105
+ 'name': target.name,
106
+ 'zones': [
107
+ {
108
+ 'unit': z.unit,
109
+ 'zone': z.zone,
110
+ 'power': z.power,
111
+ 'volume': z.volume,
112
+ 'source': z.source_id,
113
+ 'source_received': z.source_received
114
+ }
115
+ for z in self.state.get_zones_for_target(target.id)
116
+ ]
117
+ }
118
+ for target in self.config.targets
119
+ ]
120
+ }