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.
- crestron_swamp_controller-1.0.0.dist-info/METADATA +297 -0
- crestron_swamp_controller-1.0.0.dist-info/RECORD +25 -0
- crestron_swamp_controller-1.0.0.dist-info/WHEEL +5 -0
- crestron_swamp_controller-1.0.0.dist-info/entry_points.txt +2 -0
- crestron_swamp_controller-1.0.0.dist-info/licenses/LICENSE +21 -0
- crestron_swamp_controller-1.0.0.dist-info/top_level.txt +1 -0
- swamp/__init__.py +0 -0
- swamp/__main__.py +120 -0
- swamp/core/__init__.py +0 -0
- swamp/core/config_manager.py +36 -0
- swamp/core/controller.py +120 -0
- swamp/core/state_manager.py +87 -0
- swamp/models/__init__.py +0 -0
- swamp/models/commands.py +23 -0
- swamp/models/config.py +31 -0
- swamp/models/state.py +41 -0
- swamp/network/__init__.py +0 -0
- swamp/network/tcp_server.py +235 -0
- swamp/protocol/__init__.py +0 -0
- swamp/protocol/base.py +45 -0
- swamp/protocol/swamp_protocol.py +312 -0
- swamp/shell/__init__.py +0 -0
- swamp/shell/commands.py +178 -0
- swamp/shell/parser.py +36 -0
- swamp/shell/repl.py +59 -0
|
@@ -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
|
+
[](https://github.com/hacs/integration)
|
|
47
|
+
[](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,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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swamp
|
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)
|
swamp/core/controller.py
ADDED
|
@@ -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
|
+
}
|