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.
@@ -0,0 +1,6 @@
1
+ """Crestron processor provisioning console."""
2
+
3
+ try:
4
+ from ._version import version as __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m crestron_setup`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ )
@@ -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