magmascript 2.2.0__tar.gz → 3.0.0__tar.gz

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.
Files changed (91) hide show
  1. {magmascript-2.2.0 → magmascript-3.0.0}/PKG-INFO +58 -1
  2. {magmascript-2.2.0 → magmascript-3.0.0}/README.md +57 -0
  3. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/__init__.py +1 -1
  4. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/cli.py +8 -1
  5. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mc1/__init__.py +2 -1
  6. magmascript-3.0.0/magmascript/domains/mc1/client.py +248 -0
  7. magmascript-3.0.0/magmascript/domains/mc1/tools.py +190 -0
  8. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/ast_nodes.py +22 -0
  9. magmascript-3.0.0/magmascript/lang/astheno/__init__.py +202 -0
  10. magmascript-3.0.0/magmascript/lang/astheno/arena.py +251 -0
  11. magmascript-3.0.0/magmascript/lang/astheno/dump.py +144 -0
  12. magmascript-3.0.0/magmascript/lang/astheno/floorplan.py +238 -0
  13. magmascript-3.0.0/magmascript/lang/astheno/numeric.py +272 -0
  14. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/builtins.py +56 -8
  15. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/domain_bridge.py +5 -0
  16. magmascript-3.0.0/magmascript/lang/hypnagogia.py +193 -0
  17. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/interpreter.py +264 -11
  18. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/lexer.py +9 -3
  19. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/parser.py +90 -0
  20. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/tokens.py +2 -0
  21. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/repl.py +27 -4
  22. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/PKG-INFO +58 -1
  23. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/SOURCES.txt +7 -0
  24. {magmascript-2.2.0 → magmascript-3.0.0}/pyproject.toml +1 -1
  25. magmascript-3.0.0/tests/test_astheno.py +688 -0
  26. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_import.py +18 -12
  27. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_lang.py +177 -4
  28. magmascript-2.2.0/magmascript/domains/mc1/client.py +0 -122
  29. magmascript-2.2.0/magmascript/domains/mc1/tools.py +0 -82
  30. {magmascript-2.2.0 → magmascript-3.0.0}/LICENSE +0 -0
  31. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/__init__.py +0 -0
  32. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/cache.py +0 -0
  33. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/commands.py +0 -0
  34. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/config.py +0 -0
  35. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/exceptions.py +0 -0
  36. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/github.py +0 -0
  37. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/output.py +0 -0
  38. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/registry.py +0 -0
  39. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/rpc.py +0 -0
  40. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/runner.py +0 -0
  41. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/__init__.py +0 -0
  42. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/archive/__init__.py +0 -0
  43. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/archive/client.py +0 -0
  44. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/__init__.py +0 -0
  45. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/client.py +0 -0
  46. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/tools.py +0 -0
  47. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/lastfm/__init__.py +0 -0
  48. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/lastfm/client.py +0 -0
  49. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mb/__init__.py +0 -0
  50. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mb/client.py +0 -0
  51. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/__init__.py +0 -0
  52. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/client.py +0 -0
  53. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/tools.py +0 -0
  54. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/__init__.py +0 -0
  55. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/client.py +0 -0
  56. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/__init__.py +0 -0
  57. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/archive.py +0 -0
  58. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/met_museum.py +0 -0
  59. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/openverse.py +0 -0
  60. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/pexels.py +0 -0
  61. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/pixabay.py +0 -0
  62. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/smithsonian.py +0 -0
  63. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/tools.py +0 -0
  64. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/__init__.py +0 -0
  65. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/client.py +0 -0
  66. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/tools.py +0 -0
  67. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/__init__.py +0 -0
  68. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/client.py +0 -0
  69. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/tools.py +0 -0
  70. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/__init__.py +0 -0
  71. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/client.py +0 -0
  72. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/tools.py +0 -0
  73. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/search/__init__.py +0 -0
  74. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/search/client.py +0 -0
  75. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/__init__.py +0 -0
  76. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/environment.py +0 -0
  77. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/pygments_mgs.py +0 -0
  78. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/util.py +0 -0
  79. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/dependency_links.txt +0 -0
  80. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/entry_points.txt +0 -0
  81. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/requires.txt +0 -0
  82. {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/top_level.txt +0 -0
  83. {magmascript-2.2.0 → magmascript-3.0.0}/setup.cfg +0 -0
  84. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_cache.py +0 -0
  85. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_classes.py +0 -0
  86. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_cli.py +0 -0
  87. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_commands.py +0 -0
  88. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_domain_bridge.py +0 -0
  89. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_mcp_client.py +0 -0
  90. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_rpc.py +0 -0
  91. {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_try_catch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magmascript
3
- Version: 2.2.0
3
+ Version: 3.0.0
4
4
  Summary: Scripting toolkit with domain-first subcommands
5
5
  Author: Jake McCoy
6
6
  License-Expression: MIT
@@ -341,6 +341,62 @@ See `scripts/examples/` for working examples:
341
341
  | `maintenance.mgs` | Weekly maintenance pipeline |
342
342
  | `real-domains.mgs` | Test real domain connections (MCP search, scoreboards, games) |
343
343
  | `domain-example.mgs` | Domain object overview and usage patterns |
344
+ | `astheno-list.mgs` | A linked list built by hand in the arena |
345
+ | `astheno-packing.mgs` | The same struct fields, two orderings, two sizes |
346
+ | `astheno-faults.mgs` | Every memory fault, caught and named |
347
+
348
+ ### The Asthenosphere — explicit memory
349
+
350
+ The layer beneath the lithosphere. A second tier where you manage memory
351
+ yourself: a real byte arena, pointers, structs with visible padding, and
352
+ integers that wrap like C's.
353
+
354
+ It is not faster than the dynamic tier — an `i32` add still costs a Python
355
+ dispatch. What it gives you instead is the thing C cannot: the interpreter sees
356
+ every memory operation, so it catches the use-after-free, names the line that
357
+ leaked, and hex-dumps a struct with its fields labelled. **C's behavior, but
358
+ narrated.**
359
+
360
+ ```magmascript
361
+ floorplan Point {
362
+ tag: u8
363
+ x: i32
364
+ y: i32
365
+ }
366
+
367
+ layout(Point) // prints the field table, padding rows included
368
+
369
+ p = garrison(Point) // claim ground
370
+ p.x = i32(10)
371
+ p.y = i32(-20)
372
+ bathysphere(p) // annotated hex dump
373
+ scorch(p) // release it
374
+ ```
375
+
376
+ Widths are `i8 i16 i32 i64 u8 u16 u32 u64 f32 f64`. There is no integer
377
+ promotion — `i32 + u8` is an error pointing you at `osmosis()` — and `/`
378
+ truncates toward zero on integer widths, as C does.
379
+
380
+ Mistakes are reported, not swallowed:
381
+
382
+ ```
383
+ quicksand at demo.mgs:line 4, column 7
384
+ |
385
+ 4 | x = p.peek(i32)
386
+ | ^
387
+ this ground was scorched at line 3 (garrisoned at line 1)
388
+ ```
389
+
390
+ | Fault | Reported as |
391
+ |--------|-------------|
392
+ | touching scorched ground | `quicksand`, naming the line that scorched it |
393
+ | reading outside a block | `area does not exist`, with the block's extent |
394
+ | never scorching | `spooked: ancient weeds` at exit, naming each garrison line |
395
+ | arithmetic that does not fit | `spooked`, with the exact value and the wrap |
396
+
397
+ `floorplan` is the only new reserved word; everything else is a shadowable
398
+ builtin, so existing scripts are unaffected. Full reference on the
399
+ [Asthenosphere wiki page](https://github.com/magmacrunchmedia/magmascript/wiki/Asthenosphere).
344
400
 
345
401
  ### REPL
346
402
 
@@ -552,6 +608,7 @@ Full documentation on the [Wiki](https://github.com/magmacrunchmedia/magmascript
552
608
  - [Search Domain](https://github.com/magmacrunchmedia/magmascript/wiki/Search-Domain)
553
609
  - [Media Domain](https://github.com/magmacrunchmedia/magmascript/wiki/Media-Domain)
554
610
  - [Example Scripts](https://github.com/magmacrunchmedia/magmascript/wiki/Example-Scripts)
611
+ - [Asthenosphere](https://github.com/magmacrunchmedia/magmascript/wiki/Asthenosphere)
555
612
  - [Architecture](https://github.com/magmacrunchmedia/magmascript/wiki/Architecture)
556
613
 
557
614
  ## License
@@ -306,6 +306,62 @@ See `scripts/examples/` for working examples:
306
306
  | `maintenance.mgs` | Weekly maintenance pipeline |
307
307
  | `real-domains.mgs` | Test real domain connections (MCP search, scoreboards, games) |
308
308
  | `domain-example.mgs` | Domain object overview and usage patterns |
309
+ | `astheno-list.mgs` | A linked list built by hand in the arena |
310
+ | `astheno-packing.mgs` | The same struct fields, two orderings, two sizes |
311
+ | `astheno-faults.mgs` | Every memory fault, caught and named |
312
+
313
+ ### The Asthenosphere — explicit memory
314
+
315
+ The layer beneath the lithosphere. A second tier where you manage memory
316
+ yourself: a real byte arena, pointers, structs with visible padding, and
317
+ integers that wrap like C's.
318
+
319
+ It is not faster than the dynamic tier — an `i32` add still costs a Python
320
+ dispatch. What it gives you instead is the thing C cannot: the interpreter sees
321
+ every memory operation, so it catches the use-after-free, names the line that
322
+ leaked, and hex-dumps a struct with its fields labelled. **C's behavior, but
323
+ narrated.**
324
+
325
+ ```magmascript
326
+ floorplan Point {
327
+ tag: u8
328
+ x: i32
329
+ y: i32
330
+ }
331
+
332
+ layout(Point) // prints the field table, padding rows included
333
+
334
+ p = garrison(Point) // claim ground
335
+ p.x = i32(10)
336
+ p.y = i32(-20)
337
+ bathysphere(p) // annotated hex dump
338
+ scorch(p) // release it
339
+ ```
340
+
341
+ Widths are `i8 i16 i32 i64 u8 u16 u32 u64 f32 f64`. There is no integer
342
+ promotion — `i32 + u8` is an error pointing you at `osmosis()` — and `/`
343
+ truncates toward zero on integer widths, as C does.
344
+
345
+ Mistakes are reported, not swallowed:
346
+
347
+ ```
348
+ quicksand at demo.mgs:line 4, column 7
349
+ |
350
+ 4 | x = p.peek(i32)
351
+ | ^
352
+ this ground was scorched at line 3 (garrisoned at line 1)
353
+ ```
354
+
355
+ | Fault | Reported as |
356
+ |--------|-------------|
357
+ | touching scorched ground | `quicksand`, naming the line that scorched it |
358
+ | reading outside a block | `area does not exist`, with the block's extent |
359
+ | never scorching | `spooked: ancient weeds` at exit, naming each garrison line |
360
+ | arithmetic that does not fit | `spooked`, with the exact value and the wrap |
361
+
362
+ `floorplan` is the only new reserved word; everything else is a shadowable
363
+ builtin, so existing scripts are unaffected. Full reference on the
364
+ [Asthenosphere wiki page](https://github.com/magmacrunchmedia/magmascript/wiki/Asthenosphere).
309
365
 
310
366
  ### REPL
311
367
 
@@ -517,6 +573,7 @@ Full documentation on the [Wiki](https://github.com/magmacrunchmedia/magmascript
517
573
  - [Search Domain](https://github.com/magmacrunchmedia/magmascript/wiki/Search-Domain)
518
574
  - [Media Domain](https://github.com/magmacrunchmedia/magmascript/wiki/Media-Domain)
519
575
  - [Example Scripts](https://github.com/magmacrunchmedia/magmascript/wiki/Example-Scripts)
576
+ - [Asthenosphere](https://github.com/magmacrunchmedia/magmascript/wiki/Asthenosphere)
520
577
  - [Architecture](https://github.com/magmacrunchmedia/magmascript/wiki/Architecture)
521
578
 
522
579
  ## License
@@ -1,6 +1,6 @@
1
1
  """magmascript — a scripting toolkit with domain-first subcommands."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "3.0.0"
4
4
 
5
5
  from magmascript.core.config import Config, GHConfig, MediaConfig, MC1Config, PIConfig, get_config, load_config, set_config
6
6
  from magmascript.core.registry import get_domain, list_domains, register_domain
@@ -355,7 +355,7 @@ Options:
355
355
  print(f"Warning: File does not have .mgs extension: {script_path}", file=sys.stderr)
356
356
 
357
357
  try:
358
- source = script_path.read_text()
358
+ source = script_path.read_text(encoding="utf-8")
359
359
  from magmascript.lang.interpreter import Interpreter
360
360
  from magmascript.lang.lexer import Lexer
361
361
  from magmascript.lang.parser import Parser
@@ -364,7 +364,14 @@ Options:
364
364
  tokens = Lexer(source, filename=filename).tokenize()
365
365
  program = Parser(tokens, source=source, filename=filename).parse()
366
366
  interpreter = Interpreter(source=source, filename=filename, script_args=args)
367
+
368
+ # hypnagogia: look the program over at the threshold, before it runs.
369
+ from magmascript.lang.hypnagogia import inspect as _hypnagogia
370
+ for finding in _hypnagogia(program, set(interpreter.globals.variables)):
371
+ print(f"spooked: {finding.render(filename)}", file=sys.stderr)
372
+
367
373
  interpreter.run(program)
374
+ interpreter.report_leaks()
368
375
  except KeyboardInterrupt:
369
376
  print("\nInterrupted.", file=sys.stderr)
370
377
  sys.exit(130)
@@ -1,7 +1,7 @@
1
1
  """MC1 domain — exposes MC1Client and registers with the domain registry."""
2
2
 
3
3
  from magmascript.domains.mc1.client import MC1Client
4
- from magmascript.domains.mc1.tools import MC1ServiceStatus, MC1SystemInfo
4
+ from magmascript.domains.mc1.tools import MC1ServiceStatus, MC1SystemInfo, MC1PowerSettings
5
5
  from magmascript.core.registry import register_domain
6
6
 
7
7
  # Register this domain
@@ -11,4 +11,5 @@ __all__ = [
11
11
  "MC1Client",
12
12
  "MC1ServiceStatus",
13
13
  "MC1SystemInfo",
14
+ "MC1PowerSettings",
14
15
  ]
@@ -0,0 +1,248 @@
1
+ """MC1 domain — direct client for Windows PC management.
2
+
3
+ Supports SSH (remote) execution mode for managing MC1.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+
10
+ from magmascript.core.config import Config, get_config
11
+ from magmascript.core.runner import CommandRunner
12
+ from magmascript.domains.mc1.tools import (
13
+ MC1PowerSettings,
14
+ MC1ServiceStatus,
15
+ MC1SystemInfo,
16
+ parse_power_settings,
17
+ parse_service_list,
18
+ parse_system_info,
19
+ )
20
+
21
+
22
+ class MC1Client:
23
+ """Direct client for MC1 Windows PC management.
24
+
25
+ Commands run via SSH using PowerShell.
26
+ Raises SSHError on connection or command failures.
27
+ """
28
+
29
+ def __init__(self, config: Config | None = None, *, local: bool = False):
30
+ cfg = config or get_config()
31
+ self._host = cfg.mc1.host
32
+ self._user = cfg.mc1.user
33
+ self._local = local
34
+ self._runner = CommandRunner(cfg.mc1.host, cfg.mc1.user, local=local)
35
+
36
+ # ------------------------------------------------------------------
37
+ # Service management
38
+ # ------------------------------------------------------------------
39
+
40
+ def services(self) -> list[MC1ServiceStatus]:
41
+ """Get status of all running Windows services.
42
+
43
+ Raises SSHError on connection failure.
44
+ """
45
+ stdout = self._runner.run(
46
+ 'powershell -Command "Get-Service | Where-Object {$_.Status -eq \\"Running\\"} | '
47
+ 'Select-Object -Property Name, Status | ForEach-Object { \\"$($_.Name): $($_.Status)\\" }"'
48
+ )
49
+ return parse_service_list(stdout)
50
+
51
+ def restart(self, service: str) -> str:
52
+ """Restart a Windows service.
53
+
54
+ Raises SSHError on connection failure.
55
+ """
56
+ self._runner.run(
57
+ f'powershell -Command "Restart-Service -Name \\"{service}\\" -Force"',
58
+ timeout=30,
59
+ )
60
+ return f"✓ {service} restarted"
61
+
62
+ # ------------------------------------------------------------------
63
+ # System info
64
+ # ------------------------------------------------------------------
65
+
66
+ def info(self) -> MC1SystemInfo:
67
+ """Get MC1 system info (uptime, memory, CPU, disk).
68
+
69
+ Raises SSHError on connection failure.
70
+ """
71
+ stdout = self._runner.run(
72
+ 'powershell -Command "'
73
+ '$os = Get-CimInstance Win32_OperatingSystem; '
74
+ '$cpu = Get-CimInstance Win32_Processor; '
75
+ '$disk = Get-CimInstance Win32_LogicalDisk -Filter \\"DriveType=3\\"; '
76
+ 'Write-Host \\"HOSTNAME:$(hostname)\\"; '
77
+ 'Write-Host \\"UPTIME:$((Get-Date) - $os.LastBootUpTime)\\"; '
78
+ 'Write-Host \\"MEMORY:$([math]::Round($os.TotalVisibleMemorySize/1MB, 1))GB/$([math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory)/1MB, 1))GB\\"; '
79
+ 'Write-Host \\"CPU:$($cpu.LoadPercentage)%\\"; '
80
+ 'Write-Host \\"DISK:$([math]::Round($disk.FreeSpace/$disk.Size*100, 1))% free\\"; '
81
+ 'Write-Host \\"DISK_FREE_GB:$([math]::Round($disk.FreeSpace/1GB, 1))GB\\"; '
82
+ 'Write-Host \\"CPU_NAME:$($cpu.Name)\\"; '
83
+ 'Write-Host \\"CPU_CORES:$($cpu.NumberOfCores)\\"; '
84
+ 'Write-Host \\"OS_VERSION:$($os.Caption)\\"'
85
+ '"'
86
+ )
87
+ return parse_system_info(stdout)
88
+
89
+ # ------------------------------------------------------------------
90
+ # Processes
91
+ # ------------------------------------------------------------------
92
+
93
+ def processes(self) -> str:
94
+ """Get top processes on MC1 by CPU usage.
95
+
96
+ Raises SSHError on connection failure.
97
+ """
98
+ return self._runner.run(
99
+ 'powershell -Command "Get-Process | Sort-Object CPU -Descending | '
100
+ 'Select-Object -First 15 Name, CPU, WorkingSet | Format-Table -AutoSize"'
101
+ )
102
+
103
+ # ------------------------------------------------------------------
104
+ # Power
105
+ # ------------------------------------------------------------------
106
+
107
+ def reboot(self) -> str:
108
+ """Reboot MC1.
109
+
110
+ Raises SSHError on connection failure.
111
+ """
112
+ self._runner.run(
113
+ 'powershell -Command "Restart-Computer -Force"',
114
+ timeout=10,
115
+ )
116
+ return "✓ Rebooting MC1..."
117
+
118
+ # ------------------------------------------------------------------
119
+ # Wake-on-LAN
120
+ # ------------------------------------------------------------------
121
+
122
+ def wake_on_lan(self, mac_address: str) -> str:
123
+ """Send Wake-on-LAN magic packet to MC1.
124
+
125
+ This runs the `wakeonlan` command on the Pi (or local machine).
126
+ The MAC address should be MC1's ethernet adapter MAC.
127
+
128
+ Args:
129
+ mac_address: Ethernet MAC address (e.g. "A0:AD:9F:A4:72:97" or "A0-AD-9F-A4-72-97")
130
+
131
+ Returns:
132
+ Success message
133
+ """
134
+ # Normalize MAC address format (wakeonlan accepts both : and - separators)
135
+ normalized_mac = mac_address.replace("-", ":")
136
+ subprocess.run(
137
+ ["wakeonlan", normalized_mac],
138
+ capture_output=True,
139
+ timeout=10,
140
+ )
141
+ return f"✓ Wake-on-LAN packet sent to {normalized_mac}"
142
+
143
+ # ------------------------------------------------------------------
144
+ # Power management
145
+ # ------------------------------------------------------------------
146
+
147
+ def get_power_settings(self) -> MC1PowerSettings:
148
+ """Get MC1 power management settings.
149
+
150
+ Raises SSHError on connection failure.
151
+ """
152
+ stdout = self._runner.run(
153
+ 'powershell -Command "'
154
+ "$sleep_ac = (powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE).Trim().Split(':')[1].Trim().Split(' ')[0]; "
155
+ "$sleep_dc = (powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLEDC).Trim().Split(':')[1].Trim().Split(' ')[0]; "
156
+ "$hib_ac = (powercfg /query SCHEME_CURRENT SUB_SLEEP HIBERNATEIDLE).Trim().Split(':')[1].Trim().Split(' ')[0]; "
157
+ "$hib_dc = (powercfg /query SCHEME_CURRENT SUB_SLEEP HIBERNATEIDLEDC).Trim().Split(':')[1].Trim().Split(' ')[0]; "
158
+ "$hib_enabled = (powercfg /hibernate query).Contains('Hibernate'); "
159
+ "Write-Host \"SLEEP_AC:$sleep_ac\"; "
160
+ "Write-Host \"SLEEP_DC:$sleep_dc\"; "
161
+ "Write-Host \"HIBERNATE_AC:$hib_ac\"; "
162
+ "Write-Host \"HIBERNATE_DC:$hib_dc\"; "
163
+ "Write-Host \"HIBERNATE_ENABLED:$hib_enabled\""
164
+ '"'
165
+ )
166
+ settings = parse_power_settings(stdout)
167
+
168
+ # Determine power mode based on sleep timeout
169
+ if settings.sleep_timeout_ac == 0 and settings.hibernate_timeout_ac == 0:
170
+ settings.power_mode = "always-on"
171
+ else:
172
+ settings.power_mode = "sleep"
173
+
174
+ return settings
175
+
176
+ def set_sleep_timeout(self, minutes: int) -> str:
177
+ """Set sleep timeout on AC power.
178
+
179
+ Args:
180
+ minutes: Timeout in minutes (0 = never sleep)
181
+
182
+ Raises SSHError on connection failure.
183
+ """
184
+ self._runner.run(
185
+ f'powershell -Command "powercfg /change standby-timeout-ac {minutes}"',
186
+ timeout=15,
187
+ )
188
+ mode = "never sleep" if minutes == 0 else f"sleep after {minutes} minutes"
189
+ return f"✓ AC sleep timeout set to {mode}"
190
+
191
+ def set_hibernate_timeout(self, minutes: int) -> str:
192
+ """Set hibernate timeout on AC power.
193
+
194
+ Args:
195
+ minutes: Timeout in minutes (0 = never hibernate)
196
+
197
+ Raises SSHError on connection failure.
198
+ """
199
+ self._runner.run(
200
+ f'powershell -Command "powercfg /change hibernate-timeout-ac {minutes}"',
201
+ timeout=15,
202
+ )
203
+ mode = "never hibernate" if minutes == 0 else f"hibernate after {minutes} minutes"
204
+ return f"✓ AC hibernate timeout set to {mode}"
205
+
206
+ def set_power_mode(self, mode: str) -> str:
207
+ """Set power mode to either 'always-on' or 'sleep'.
208
+
209
+ Args:
210
+ mode: "always-on" or "sleep" (with 30-minute default timeout)
211
+
212
+ Raises SSHError on connection failure.
213
+ """
214
+ if mode == "always-on":
215
+ self.set_sleep_timeout(0)
216
+ self.set_hibernate_timeout(0)
217
+ return "✓ Power mode set to ALWAYS-ON (sleep disabled)"
218
+ elif mode == "sleep":
219
+ self.set_sleep_timeout(30)
220
+ self.set_hibernate_timeout(60)
221
+ return "✓ Power mode set to SLEEP (30min sleep, 60min hibernate)"
222
+ else:
223
+ raise ValueError(f"Unknown power mode: {mode}. Use 'always-on' or 'sleep'")
224
+
225
+ def disable_fast_startup(self) -> str:
226
+ """Disable Windows fast startup for reliable Wake-on-LAN.
227
+
228
+ Raises SSHError on connection failure.
229
+ """
230
+ self._runner.run(
231
+ 'powershell -Command "powercfg /hibernate on; powercfg /change hibernate-timeout-ac 0"',
232
+ timeout=15,
233
+ )
234
+ return "✓ Fast startup disabled (hibernate file enabled for WoL)"
235
+
236
+ # ------------------------------------------------------------------
237
+ # Lifecycle
238
+ # ------------------------------------------------------------------
239
+
240
+ def close(self):
241
+ """No-op."""
242
+ pass
243
+
244
+ def __enter__(self):
245
+ return self
246
+
247
+ def __exit__(self, *args):
248
+ self.close()
@@ -0,0 +1,190 @@
1
+ """Typed result dataclasses for the MC1 domain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class MC1ServiceStatus:
10
+ """Status of a Windows service."""
11
+
12
+ name: str
13
+ status: str
14
+ ok: bool
15
+
16
+
17
+ @dataclass
18
+ class MC1SystemInfo:
19
+ """MC1 system info."""
20
+
21
+ hostname: str
22
+ uptime: str
23
+ memory: str
24
+ cpu_load: str
25
+ disk_free: str
26
+ disk_free_gb: str = ""
27
+ cpu_name: str = ""
28
+ cpu_cores: str = ""
29
+ os_version: str = ""
30
+
31
+
32
+ @dataclass
33
+ class MC1PowerSettings:
34
+ """MC1 power management settings."""
35
+
36
+ sleep_timeout_ac: int # minutes (0 = never)
37
+ sleep_timeout_dc: int # minutes (0 = never)
38
+ hibernate_timeout_ac: int # minutes (0 = never)
39
+ hibernate_timeout_dc: int # minutes (0 = never)
40
+ power_mode: str # "always-on" or "sleep"
41
+ hibernate_enabled: bool = True
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Uptime formatting
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def _format_uptime(raw: str) -> str:
50
+ """Parse .NET TimeSpan string into human-readable form.
51
+
52
+ Two formats from PowerShell:
53
+ >1 day: '1.03:05:36.7441121' (dot = day separator)
54
+ <1 day: '07:33:06.0140847' (dot = fractional seconds)
55
+ """
56
+ try:
57
+ parts = raw.split(".", 1)
58
+ if ":" in parts[0]:
59
+ # No day component: "07:33:06.0140847"
60
+ time_parts = parts[0].split(":")
61
+ days = 0
62
+ else:
63
+ # Day component: "1.03:05:36.7441121"
64
+ days = int(parts[0]) if parts[0].isdigit() else 0
65
+ time_parts = parts[1].split(":") if len(parts) > 1 else []
66
+
67
+ hours = int(time_parts[0]) if len(time_parts) > 0 else 0
68
+ minutes = int(time_parts[1]) if len(time_parts) > 1 else 0
69
+ # Seconds may have fractional part attached: "45.1234567"
70
+ sec_str = time_parts[2].split(".")[0] if len(time_parts) > 2 else "0"
71
+ seconds = int(sec_str) if sec_str.isdigit() else 0
72
+
73
+ segments = []
74
+ if days > 0:
75
+ segments.append(f"{days} day{'s' if days != 1 else ''}")
76
+ if hours > 0:
77
+ segments.append(f"{hours} hour{'s' if hours != 1 else ''}")
78
+ if minutes > 0:
79
+ segments.append(f"{minutes} min{'s' if minutes != 1 else ''}")
80
+ if seconds > 0:
81
+ segments.append(f"{seconds}s")
82
+ if not segments:
83
+ segments.append("0s")
84
+
85
+ return ", ".join(segments)
86
+ except (ValueError, IndexError):
87
+ return raw
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Parsers
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def parse_service_list(text: str) -> list[MC1ServiceStatus]:
96
+ """Parse PowerShell Get-Service output into MC1ServiceStatus list.
97
+
98
+ Expected format (from the PowerShell command):
99
+ AmdPpkgSvc: Running
100
+ AppIDSvc: Running
101
+ """
102
+ results = []
103
+ for line in text.splitlines():
104
+ line = line.strip()
105
+ if ":" in line:
106
+ name, status = line.split(":", 1)
107
+ name = name.strip()
108
+ status = status.strip()
109
+ results.append(MC1ServiceStatus(
110
+ name=name,
111
+ status=status,
112
+ ok=status.lower() == "running",
113
+ ))
114
+ return results
115
+
116
+
117
+ def parse_system_info(text: str) -> MC1SystemInfo:
118
+ """Parse the output of the combined system info command.
119
+
120
+ Expected format:
121
+ HOSTNAME:MC1
122
+ UPTIME:07:33:06.0140847
123
+ MEMORY:31.7GB/7.6GB
124
+ CPU:4%
125
+ DISK:93.2% free
126
+ DISK_FREE_GB:776.9GB
127
+ CPU_NAME:AMD Ryzen 7 8700F 8-Core Processor
128
+ CPU_CORES:8
129
+ OS_VERSION:Microsoft Windows 11 Home
130
+ """
131
+ info = {
132
+ "hostname": "", "uptime": "", "memory": "", "cpu_load": "",
133
+ "disk_free": "", "disk_free_gb": "", "cpu_name": "",
134
+ "cpu_cores": "", "os_version": "",
135
+ }
136
+ for line in text.splitlines():
137
+ line = line.strip()
138
+ if ":" in line:
139
+ key, val = line.split(":", 1)
140
+ key = key.lower().strip()
141
+ val = val.strip()
142
+ if key in info:
143
+ info[key] = val
144
+
145
+ # Format uptime from raw TimeSpan to human-readable
146
+ if info["uptime"]:
147
+ info["uptime"] = _format_uptime(info["uptime"])
148
+
149
+ return MC1SystemInfo(**info)
150
+
151
+
152
+ def parse_power_settings(text: str) -> MC1PowerSettings:
153
+ """Parse the output of the power settings command.
154
+
155
+ Expected format:
156
+ SLEEP_AC:30
157
+ SLEEP_DC:15
158
+ HIBERNATE_AC:0
159
+ HIBERNATE_DC:0
160
+ POWER_MODE:sleep
161
+ HIBERNATE_ENABLED:True
162
+ """
163
+ info = {
164
+ "sleep_timeout_ac": 30,
165
+ "sleep_timeout_dc": 15,
166
+ "hibernate_timeout_ac": 0,
167
+ "hibernate_timeout_dc": 0,
168
+ "power_mode": "sleep",
169
+ "hibernate_enabled": True,
170
+ }
171
+ for line in text.splitlines():
172
+ line = line.strip()
173
+ if ":" in line:
174
+ key, val = line.split(":", 1)
175
+ key = key.lower().strip()
176
+ val = val.strip()
177
+ if key == "sleep_ac":
178
+ info["sleep_timeout_ac"] = int(val) if val.isdigit() else 0
179
+ elif key == "sleep_dc":
180
+ info["sleep_timeout_dc"] = int(val) if val.isdigit() else 0
181
+ elif key == "hibernate_ac":
182
+ info["hibernate_timeout_ac"] = int(val) if val.isdigit() else 0
183
+ elif key == "hibernate_dc":
184
+ info["hibernate_timeout_dc"] = int(val) if val.isdigit() else 0
185
+ elif key == "power_mode":
186
+ info["power_mode"] = val if val in ("always-on", "sleep") else "sleep"
187
+ elif key == "hibernate_enabled":
188
+ info["hibernate_enabled"] = val.lower() == "true"
189
+
190
+ return MC1PowerSettings(**info)
@@ -223,6 +223,28 @@ class PropertyAssignment(ASTNode):
223
223
  op: str = "="
224
224
 
225
225
 
226
+ @dataclass
227
+ class FloorplanField(ASTNode):
228
+ name: str = ""
229
+ type_name: str = ""
230
+ count: int = 1
231
+ points_to: str = ""
232
+
233
+
234
+ @dataclass
235
+ class FloorplanDef(ASTNode):
236
+ name: str = ""
237
+ fields: list[FloorplanField] = field(default_factory=list)
238
+
239
+
240
+ @dataclass
241
+ class IndexAssignment(ASTNode):
242
+ object: ASTNode = field(default_factory=ASTNode)
243
+ index: ASTNode = field(default_factory=ASTNode)
244
+ value: ASTNode = field(default_factory=ASTNode)
245
+ op: str = "="
246
+
247
+
226
248
  @dataclass
227
249
  class MultiAssignment(ASTNode):
228
250
  targets: list[str] = field(default_factory=list)