crestron-setup 0.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_setup/__init__.py +6 -0
- crestron_setup/__main__.py +6 -0
- crestron_setup/cli.py +481 -0
- crestron_setup/config.py +109 -0
- crestron_setup/discovery.py +193 -0
- crestron_setup/firmware.py +151 -0
- crestron_setup/models.py +64 -0
- crestron_setup/provisioning.py +735 -0
- crestron_setup/ssh.py +339 -0
- crestron_setup/timezones.py +168 -0
- crestron_setup-0.0.0.dist-info/METADATA +105 -0
- crestron_setup-0.0.0.dist-info/RECORD +16 -0
- crestron_setup-0.0.0.dist-info/WHEEL +5 -0
- crestron_setup-0.0.0.dist-info/entry_points.txt +2 -0
- crestron_setup-0.0.0.dist-info/licenses/LICENSE +21 -0
- crestron_setup-0.0.0.dist-info/top_level.txt +1 -0
crestron_setup/cli.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Interactive CLI console for Crestron processor provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from .config import load_config, save_config
|
|
14
|
+
from .discovery import discover_devices, print_device_table
|
|
15
|
+
from .firmware import download_firmware, find_local_firmware
|
|
16
|
+
from .models import Config, Device, NetworkConfig
|
|
17
|
+
from .provisioning import provision_device, restore_device
|
|
18
|
+
from .ssh import CrestronFirstBoot
|
|
19
|
+
from .timezones import timezone_choices, timezone_label
|
|
20
|
+
from . import __version__
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clear() -> None:
|
|
26
|
+
"""Clear the terminal screen."""
|
|
27
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _banner() -> None:
|
|
31
|
+
"""Print the application banner at the top of the screen."""
|
|
32
|
+
title = Text("Crestron Processor Setup", style="bold cyan")
|
|
33
|
+
subtitle = Text(f"v{__version__}", style="dim")
|
|
34
|
+
console.print(
|
|
35
|
+
Panel(
|
|
36
|
+
Text.assemble(title, " ", subtitle),
|
|
37
|
+
border_style="cyan",
|
|
38
|
+
padding=(0, 2),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
console.print()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _header(section: str) -> None:
|
|
45
|
+
"""Clear screen and show banner + section header."""
|
|
46
|
+
_clear()
|
|
47
|
+
_banner()
|
|
48
|
+
console.rule(f"[bold]{section}[/bold]")
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pause() -> None:
|
|
53
|
+
"""Wait for user to press Enter before returning to menu."""
|
|
54
|
+
console.print()
|
|
55
|
+
questionary.press_any_key_to_continue("Press any key to continue...").ask()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> None:
|
|
59
|
+
"""Entry point for the Crestron setup console."""
|
|
60
|
+
config = load_config()
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
_clear()
|
|
64
|
+
_banner()
|
|
65
|
+
|
|
66
|
+
choice = questionary.select(
|
|
67
|
+
"Main Menu",
|
|
68
|
+
choices=[
|
|
69
|
+
questionary.Choice("Discover Devices", value="discover"),
|
|
70
|
+
questionary.Choice("Setup Device (manual IP)", value="setup"),
|
|
71
|
+
questionary.Choice("Restore & Erase Device", value="restore"),
|
|
72
|
+
questionary.Choice("Download Firmware", value="firmware"),
|
|
73
|
+
questionary.Choice("Settings", value="settings"),
|
|
74
|
+
questionary.Choice("Exit", value="exit"),
|
|
75
|
+
],
|
|
76
|
+
).ask()
|
|
77
|
+
|
|
78
|
+
if choice is None or choice == "exit":
|
|
79
|
+
_clear()
|
|
80
|
+
console.print("[dim]Goodbye.[/dim]")
|
|
81
|
+
break
|
|
82
|
+
elif choice == "discover":
|
|
83
|
+
_flow_discover(config)
|
|
84
|
+
elif choice == "setup":
|
|
85
|
+
_flow_manual_setup(config)
|
|
86
|
+
elif choice == "restore":
|
|
87
|
+
_flow_restore()
|
|
88
|
+
elif choice == "firmware":
|
|
89
|
+
_flow_firmware(config)
|
|
90
|
+
elif choice == "settings":
|
|
91
|
+
config = _flow_settings(config)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
# Discovery flow
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _flow_discover(config: Config) -> None:
|
|
100
|
+
"""Discover devices on the LAN, check first-boot state, select, and provision."""
|
|
101
|
+
_header("Discover Devices")
|
|
102
|
+
devices = discover_devices(config, console)
|
|
103
|
+
|
|
104
|
+
if not devices:
|
|
105
|
+
console.print("[yellow]No devices found.[/yellow] "
|
|
106
|
+
"Make sure you're on the same subnet and running with "
|
|
107
|
+
"elevated privileges (sudo).")
|
|
108
|
+
_pause()
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Check first-boot state for each device
|
|
112
|
+
console.print("Checking first-boot state...")
|
|
113
|
+
for dev in devices:
|
|
114
|
+
dev.is_first_boot = CrestronFirstBoot.check_first_boot(dev.ip)
|
|
115
|
+
|
|
116
|
+
_header("Discover Devices")
|
|
117
|
+
print_device_table(devices, console)
|
|
118
|
+
console.print()
|
|
119
|
+
|
|
120
|
+
# Let user select devices
|
|
121
|
+
choices = [
|
|
122
|
+
questionary.Choice(
|
|
123
|
+
f"{dev.ip:<17} {dev.hostname:<20} {dev.model:<12} "
|
|
124
|
+
f"{'[FIRST BOOT]' if dev.is_first_boot else ''}",
|
|
125
|
+
value=i,
|
|
126
|
+
)
|
|
127
|
+
for i, dev in enumerate(devices)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
selected_indices = questionary.checkbox(
|
|
131
|
+
"Select devices to provision:",
|
|
132
|
+
choices=choices,
|
|
133
|
+
).ask()
|
|
134
|
+
|
|
135
|
+
if not selected_indices:
|
|
136
|
+
console.print("[dim]No devices selected.[/dim]")
|
|
137
|
+
_pause()
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
selected_devices = [devices[i] for i in selected_indices]
|
|
141
|
+
|
|
142
|
+
# Get credentials
|
|
143
|
+
creds = _prompt_credentials()
|
|
144
|
+
if not creds:
|
|
145
|
+
return
|
|
146
|
+
username, password = creds
|
|
147
|
+
|
|
148
|
+
# Network configuration per device
|
|
149
|
+
if len(selected_devices) == 1:
|
|
150
|
+
net = _prompt_network_config(selected_devices[0].ip)
|
|
151
|
+
if net:
|
|
152
|
+
selected_devices[0].network = net
|
|
153
|
+
else:
|
|
154
|
+
net_mode = questionary.select(
|
|
155
|
+
"Network configuration:",
|
|
156
|
+
choices=[
|
|
157
|
+
questionary.Choice("Skip (keep DHCP on all)", value="skip"),
|
|
158
|
+
questionary.Choice("Configure each device individually", value="each"),
|
|
159
|
+
],
|
|
160
|
+
).ask()
|
|
161
|
+
if net_mode == "each":
|
|
162
|
+
for dev in selected_devices:
|
|
163
|
+
net = _prompt_network_config(dev.ip)
|
|
164
|
+
if net:
|
|
165
|
+
dev.network = net
|
|
166
|
+
|
|
167
|
+
# Provision each device sequentially
|
|
168
|
+
for dev in selected_devices:
|
|
169
|
+
provision_device(dev, username, password, config, console)
|
|
170
|
+
_pause()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# Manual setup flow
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _flow_manual_setup(config: Config) -> None:
|
|
179
|
+
"""Provision a single device by IP/hostname."""
|
|
180
|
+
_header("Setup Device")
|
|
181
|
+
host = questionary.text("Processor hostname or IP:").ask()
|
|
182
|
+
if not host:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
creds = _prompt_credentials()
|
|
186
|
+
if not creds:
|
|
187
|
+
return
|
|
188
|
+
username, password = creds
|
|
189
|
+
|
|
190
|
+
device = Device(ip=host)
|
|
191
|
+
|
|
192
|
+
# Quick first-boot check
|
|
193
|
+
console.print("Checking first-boot state...")
|
|
194
|
+
device.is_first_boot = CrestronFirstBoot.check_first_boot(host)
|
|
195
|
+
if device.is_first_boot:
|
|
196
|
+
console.print("[cyan][INFO][/cyan] Device appears to be in first-boot mode.")
|
|
197
|
+
|
|
198
|
+
# Network configuration
|
|
199
|
+
net = _prompt_network_config(host)
|
|
200
|
+
if net:
|
|
201
|
+
device.network = net
|
|
202
|
+
|
|
203
|
+
provision_device(device, username, password, config, console)
|
|
204
|
+
_pause()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
# Restore & Erase flow
|
|
209
|
+
# --------------------------------------------------------------------------- #
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _flow_restore() -> None:
|
|
213
|
+
"""Restore a device to factory defaults (initialize + restore)."""
|
|
214
|
+
_header("Restore & Erase Device")
|
|
215
|
+
console.print("[yellow][WARN][/yellow] This will erase all settings and programs "
|
|
216
|
+
"and restore the device to factory defaults.\n")
|
|
217
|
+
|
|
218
|
+
host = questionary.text("Processor hostname or IP:").ask()
|
|
219
|
+
if not host:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
username = questionary.text("Admin username:").ask()
|
|
223
|
+
if not username:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
password = questionary.password("Admin password:").ask()
|
|
227
|
+
if not password:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
confirm = questionary.confirm(
|
|
231
|
+
f"Are you sure you want to erase and restore {host}?",
|
|
232
|
+
default=False,
|
|
233
|
+
).ask()
|
|
234
|
+
if not confirm:
|
|
235
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
236
|
+
_pause()
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
device = Device(ip=host)
|
|
240
|
+
restore_device(device, username, password, console)
|
|
241
|
+
_pause()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
# Firmware download flow
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _flow_firmware(config: Config) -> None:
|
|
250
|
+
"""Download firmware for a specific model."""
|
|
251
|
+
_header("Download Firmware")
|
|
252
|
+
available = list(config.firmware_urls.keys())
|
|
253
|
+
if not available:
|
|
254
|
+
console.print(
|
|
255
|
+
"[yellow]No firmware URLs configured.[/yellow]\n"
|
|
256
|
+
"Add entries under firmware_urls in your config.yaml.\n"
|
|
257
|
+
"See config.example.yaml for the format."
|
|
258
|
+
)
|
|
259
|
+
_pause()
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
model = questionary.select(
|
|
263
|
+
"Select model to download firmware for:",
|
|
264
|
+
choices=available + ["Cancel"],
|
|
265
|
+
).ask()
|
|
266
|
+
|
|
267
|
+
if not model or model == "Cancel":
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Check for existing local firmware first
|
|
271
|
+
fw_path, fw_version = find_local_firmware(model, config)
|
|
272
|
+
if fw_path:
|
|
273
|
+
console.print(f"[cyan][INFO][/cyan] Local firmware found: {fw_path.name} (v{fw_version})")
|
|
274
|
+
if not questionary.confirm("Download anyway?", default=False).ask():
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
download_firmware(model, config, console)
|
|
278
|
+
_pause()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------- #
|
|
282
|
+
# Settings flow
|
|
283
|
+
# --------------------------------------------------------------------------- #
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _flow_settings(config: Config) -> Config:
|
|
287
|
+
"""View and edit configuration."""
|
|
288
|
+
while True:
|
|
289
|
+
_header("Settings")
|
|
290
|
+
console.print("[bold]Current Settings[/bold]")
|
|
291
|
+
console.print(f" Timezone: {config.timezone} — {timezone_label(config.timezone)}")
|
|
292
|
+
console.print(f" NTP Server: {config.ntp_server}")
|
|
293
|
+
console.print(f" Public Key: {config.pubkey_file}")
|
|
294
|
+
console.print(f" Firmware Directory: {config.firmware_dir}")
|
|
295
|
+
console.print(f" Web Port: {config.web_port}")
|
|
296
|
+
console.print(f" Secure Web Port: {config.secure_web_port}")
|
|
297
|
+
console.print(f" FIPS Mode: {config.fips_mode}")
|
|
298
|
+
console.print(f" Firmware URLs: {len(config.firmware_urls)} configured")
|
|
299
|
+
console.print()
|
|
300
|
+
|
|
301
|
+
action = questionary.select(
|
|
302
|
+
"Settings",
|
|
303
|
+
choices=[
|
|
304
|
+
questionary.Choice("Edit a setting", value="edit"),
|
|
305
|
+
questionary.Choice("Add firmware URL", value="add_fw"),
|
|
306
|
+
questionary.Choice("Save to disk", value="save"),
|
|
307
|
+
questionary.Choice("Back to main menu", value="back"),
|
|
308
|
+
],
|
|
309
|
+
).ask()
|
|
310
|
+
|
|
311
|
+
if action is None or action == "back":
|
|
312
|
+
return config
|
|
313
|
+
elif action == "edit":
|
|
314
|
+
config = _edit_setting(config)
|
|
315
|
+
elif action == "add_fw":
|
|
316
|
+
config = _add_firmware_url(config)
|
|
317
|
+
elif action == "save":
|
|
318
|
+
path = save_config(config)
|
|
319
|
+
console.print(f"[green][OK][/green] Config saved to {path}")
|
|
320
|
+
|
|
321
|
+
return config
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _edit_setting(config: Config) -> Config:
|
|
325
|
+
"""Edit a single config setting."""
|
|
326
|
+
field = questionary.select(
|
|
327
|
+
"Which setting?",
|
|
328
|
+
choices=[
|
|
329
|
+
questionary.Choice("Timezone", value="timezone"),
|
|
330
|
+
questionary.Choice("NTP Server", value="ntp_server"),
|
|
331
|
+
questionary.Choice("Public Key File", value="pubkey_file"),
|
|
332
|
+
questionary.Choice("Firmware Directory", value="firmware_dir"),
|
|
333
|
+
questionary.Choice("Web Port", value="web_port"),
|
|
334
|
+
questionary.Choice("Secure Web Port", value="secure_web_port"),
|
|
335
|
+
questionary.Choice("FIPS Mode", value="fips_mode"),
|
|
336
|
+
questionary.Choice("Cancel", value="cancel"),
|
|
337
|
+
],
|
|
338
|
+
).ask()
|
|
339
|
+
|
|
340
|
+
if not field or field == "cancel":
|
|
341
|
+
return config
|
|
342
|
+
|
|
343
|
+
if field == "timezone":
|
|
344
|
+
return _pick_timezone(config)
|
|
345
|
+
|
|
346
|
+
current = getattr(config, field)
|
|
347
|
+
new_value = questionary.text(
|
|
348
|
+
f"{field}:", default=str(current)
|
|
349
|
+
).ask()
|
|
350
|
+
|
|
351
|
+
if new_value is not None:
|
|
352
|
+
if field in ("web_port", "secure_web_port"):
|
|
353
|
+
setattr(config, field, int(new_value))
|
|
354
|
+
else:
|
|
355
|
+
setattr(config, field, new_value)
|
|
356
|
+
|
|
357
|
+
return config
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _pick_timezone(config: Config) -> Config:
|
|
361
|
+
"""Interactive timezone picker with type-to-filter."""
|
|
362
|
+
choices = [
|
|
363
|
+
questionary.Choice(label, value=tz_id)
|
|
364
|
+
for tz_id, label in timezone_choices()
|
|
365
|
+
]
|
|
366
|
+
current = config.timezone.zfill(3)
|
|
367
|
+
# Find the matching choice to set as default
|
|
368
|
+
default = None
|
|
369
|
+
for c in choices:
|
|
370
|
+
if c.value == current:
|
|
371
|
+
default = c.value
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
result = questionary.select(
|
|
375
|
+
"Select timezone (type to filter):",
|
|
376
|
+
choices=choices,
|
|
377
|
+
default=default,
|
|
378
|
+
use_shortcuts=False,
|
|
379
|
+
).ask()
|
|
380
|
+
|
|
381
|
+
if result is not None:
|
|
382
|
+
config.timezone = result
|
|
383
|
+
return config
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _add_firmware_url(config: Config) -> Config:
|
|
387
|
+
"""Add or update a firmware download URL."""
|
|
388
|
+
model = questionary.text("Model name (e.g., CP4, MC4):").ask()
|
|
389
|
+
if not model:
|
|
390
|
+
return config
|
|
391
|
+
|
|
392
|
+
url = questionary.text("Firmware download URL:").ask()
|
|
393
|
+
if not url:
|
|
394
|
+
return config
|
|
395
|
+
|
|
396
|
+
auth_header = questionary.text(
|
|
397
|
+
"Authorization header (optional, e.g., Bearer TOKEN):",
|
|
398
|
+
default="",
|
|
399
|
+
).ask()
|
|
400
|
+
|
|
401
|
+
from .models import FirmwareSource
|
|
402
|
+
|
|
403
|
+
headers = {}
|
|
404
|
+
if auth_header:
|
|
405
|
+
headers["Authorization"] = auth_header
|
|
406
|
+
|
|
407
|
+
config.firmware_urls[model.upper()] = FirmwareSource(url=url, headers=headers)
|
|
408
|
+
console.print(f"[green][OK][/green] Firmware URL added for {model.upper()}")
|
|
409
|
+
return config
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# --------------------------------------------------------------------------- #
|
|
413
|
+
# Helpers
|
|
414
|
+
# --------------------------------------------------------------------------- #
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _prompt_credentials() -> tuple[str, str] | None:
|
|
418
|
+
"""Prompt for admin username and password."""
|
|
419
|
+
username = questionary.text("Admin username:").ask()
|
|
420
|
+
if not username:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
password = questionary.password("Admin password:").ask()
|
|
424
|
+
if not password:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
confirm = questionary.password("Confirm password:").ask()
|
|
428
|
+
if password != confirm:
|
|
429
|
+
console.print("[red]Passwords do not match.[/red]")
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
return username, password
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _prompt_network_config(device_label: str = "") -> NetworkConfig | None:
|
|
436
|
+
"""Prompt for network configuration (DHCP or static IP)."""
|
|
437
|
+
prefix = f"[{device_label}] " if device_label else ""
|
|
438
|
+
|
|
439
|
+
mode = questionary.select(
|
|
440
|
+
f"{prefix}IP address mode:",
|
|
441
|
+
choices=[
|
|
442
|
+
questionary.Choice("DHCP (no changes needed)", value="dhcp"),
|
|
443
|
+
questionary.Choice("Static IP", value="static"),
|
|
444
|
+
questionary.Choice("Skip network config", value="skip"),
|
|
445
|
+
],
|
|
446
|
+
).ask()
|
|
447
|
+
|
|
448
|
+
if mode is None or mode == "skip":
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
if mode == "dhcp":
|
|
452
|
+
return NetworkConfig(mode="dhcp")
|
|
453
|
+
|
|
454
|
+
ip = questionary.text(f"{prefix}IP address:").ask()
|
|
455
|
+
if not ip:
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
mask = questionary.text(
|
|
459
|
+
f"{prefix}Subnet mask:", default="255.255.255.0"
|
|
460
|
+
).ask()
|
|
461
|
+
if not mask:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
gw = questionary.text(f"{prefix}Default gateway:").ask()
|
|
465
|
+
if not gw:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
dns_input = questionary.text(
|
|
469
|
+
f"{prefix}DNS servers (comma-separated, or blank to skip):",
|
|
470
|
+
default="",
|
|
471
|
+
).ask()
|
|
472
|
+
|
|
473
|
+
dns_servers = [s.strip() for s in (dns_input or "").split(",") if s.strip()]
|
|
474
|
+
|
|
475
|
+
return NetworkConfig(
|
|
476
|
+
mode="static",
|
|
477
|
+
ip_address=ip,
|
|
478
|
+
subnet_mask=mask,
|
|
479
|
+
gateway=gw,
|
|
480
|
+
dns_servers=dns_servers,
|
|
481
|
+
)
|
crestron_setup/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Configuration loading and management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .models import Config, FirmwareSource
|
|
12
|
+
|
|
13
|
+
APP_NAME = "crestron-setup"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _config_dir() -> Path:
|
|
17
|
+
"""Return the platform-appropriate config directory."""
|
|
18
|
+
if platform.system() == "Windows":
|
|
19
|
+
base = os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")
|
|
20
|
+
return Path(base) / APP_NAME
|
|
21
|
+
return Path.home() / ".config" / APP_NAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _config_path() -> Path:
|
|
25
|
+
return _config_dir() / "config.yaml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_firmware_urls(raw: dict | None) -> dict[str, FirmwareSource]:
|
|
29
|
+
"""Parse firmware_urls section from YAML into FirmwareSource objects."""
|
|
30
|
+
if not raw:
|
|
31
|
+
return {}
|
|
32
|
+
result: dict[str, FirmwareSource] = {}
|
|
33
|
+
for model, value in raw.items():
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
result[model.upper()] = FirmwareSource(url=value)
|
|
36
|
+
elif isinstance(value, dict):
|
|
37
|
+
result[model.upper()] = FirmwareSource(
|
|
38
|
+
url=value.get("url", ""),
|
|
39
|
+
headers=value.get("headers", {}),
|
|
40
|
+
)
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> Config:
|
|
45
|
+
"""Load config from YAML, falling back to defaults for missing keys."""
|
|
46
|
+
# Check local config first, then platform config dir
|
|
47
|
+
local = Path("config.yaml")
|
|
48
|
+
path = local if local.exists() else _config_path()
|
|
49
|
+
|
|
50
|
+
data: dict = {}
|
|
51
|
+
if path.exists():
|
|
52
|
+
with open(path) as f:
|
|
53
|
+
data = yaml.safe_load(f) or {}
|
|
54
|
+
|
|
55
|
+
discovery = data.get("discovery", {})
|
|
56
|
+
|
|
57
|
+
return Config(
|
|
58
|
+
timezone=str(data.get("timezone", "33")),
|
|
59
|
+
ntp_server=data.get("ntp_server", "pool.ntp.org"),
|
|
60
|
+
pubkey_file=data.get("pubkey_file", "~/.ssh/id_rsa.pub"),
|
|
61
|
+
firmware_dir=data.get("firmware_dir", "~/Sync/Crestron Firmware"),
|
|
62
|
+
web_port=int(data.get("web_port", 8080)),
|
|
63
|
+
secure_web_port=int(data.get("secure_web_port", 8443)),
|
|
64
|
+
user_login_attempts=int(data.get("user_login_attempts", 5)),
|
|
65
|
+
user_lockout_time=str(data.get("user_lockout_time", "1m")),
|
|
66
|
+
login_attempts=int(data.get("login_attempts", 20)),
|
|
67
|
+
lockout_time=str(data.get("lockout_time", "5m")),
|
|
68
|
+
fips_mode=str(data.get("fips_mode", "OFF")).upper(),
|
|
69
|
+
firmware_urls=_parse_firmware_urls(data.get("firmware_urls")),
|
|
70
|
+
discovery_timeout=int(discovery.get("timeout", 5)),
|
|
71
|
+
discovery_broadcast_count=int(discovery.get("broadcast_count", 3)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_config(config: Config) -> Path:
|
|
76
|
+
"""Save current config to the platform config directory."""
|
|
77
|
+
path = _config_path()
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Build firmware_urls for YAML
|
|
81
|
+
fw_urls: dict = {}
|
|
82
|
+
for model, src in config.firmware_urls.items():
|
|
83
|
+
if src.headers:
|
|
84
|
+
fw_urls[model] = {"url": src.url, "headers": src.headers}
|
|
85
|
+
else:
|
|
86
|
+
fw_urls[model] = src.url
|
|
87
|
+
|
|
88
|
+
data = {
|
|
89
|
+
"timezone": config.timezone,
|
|
90
|
+
"ntp_server": config.ntp_server,
|
|
91
|
+
"pubkey_file": config.pubkey_file,
|
|
92
|
+
"firmware_dir": config.firmware_dir,
|
|
93
|
+
"web_port": config.web_port,
|
|
94
|
+
"secure_web_port": config.secure_web_port,
|
|
95
|
+
"user_login_attempts": config.user_login_attempts,
|
|
96
|
+
"user_lockout_time": config.user_lockout_time,
|
|
97
|
+
"login_attempts": config.login_attempts,
|
|
98
|
+
"lockout_time": config.lockout_time,
|
|
99
|
+
"fips_mode": config.fips_mode,
|
|
100
|
+
"firmware_urls": fw_urls,
|
|
101
|
+
"discovery": {
|
|
102
|
+
"timeout": config.discovery_timeout,
|
|
103
|
+
"broadcast_count": config.discovery_broadcast_count,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
with open(path, "w") as f:
|
|
108
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
109
|
+
return path
|