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.
- {magmascript-2.2.0 → magmascript-3.0.0}/PKG-INFO +58 -1
- {magmascript-2.2.0 → magmascript-3.0.0}/README.md +57 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/__init__.py +1 -1
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/cli.py +8 -1
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mc1/__init__.py +2 -1
- magmascript-3.0.0/magmascript/domains/mc1/client.py +248 -0
- magmascript-3.0.0/magmascript/domains/mc1/tools.py +190 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/ast_nodes.py +22 -0
- magmascript-3.0.0/magmascript/lang/astheno/__init__.py +202 -0
- magmascript-3.0.0/magmascript/lang/astheno/arena.py +251 -0
- magmascript-3.0.0/magmascript/lang/astheno/dump.py +144 -0
- magmascript-3.0.0/magmascript/lang/astheno/floorplan.py +238 -0
- magmascript-3.0.0/magmascript/lang/astheno/numeric.py +272 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/builtins.py +56 -8
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/domain_bridge.py +5 -0
- magmascript-3.0.0/magmascript/lang/hypnagogia.py +193 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/interpreter.py +264 -11
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/lexer.py +9 -3
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/parser.py +90 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/tokens.py +2 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/repl.py +27 -4
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/PKG-INFO +58 -1
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/SOURCES.txt +7 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/pyproject.toml +1 -1
- magmascript-3.0.0/tests/test_astheno.py +688 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_import.py +18 -12
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_lang.py +177 -4
- magmascript-2.2.0/magmascript/domains/mc1/client.py +0 -122
- magmascript-2.2.0/magmascript/domains/mc1/tools.py +0 -82
- {magmascript-2.2.0 → magmascript-3.0.0}/LICENSE +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/cache.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/commands.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/config.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/exceptions.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/github.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/output.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/registry.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/rpc.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/core/runner.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/archive/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/archive/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/gh/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/lastfm/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/lastfm/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mb/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mb/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/mcp/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/archive.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/met_museum.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/openverse.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/pexels.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/pixabay.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/providers/smithsonian.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/media/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/pi/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/rights/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/scores/tools.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/search/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/domains/search/client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/__init__.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/environment.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/pygments_mgs.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript/lang/util.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/dependency_links.txt +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/entry_points.txt +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/requires.txt +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/magmascript.egg-info/top_level.txt +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/setup.cfg +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_cache.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_classes.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_cli.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_commands.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_domain_bridge.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_mcp_client.py +0 -0
- {magmascript-2.2.0 → magmascript-3.0.0}/tests/test_rpc.py +0 -0
- {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:
|
|
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__ = "
|
|
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)
|