fiberpath 0.3.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.
Files changed (49) hide show
  1. fiberpath/__init__.py +10 -0
  2. fiberpath/config/__init__.py +22 -0
  3. fiberpath/config/schemas.py +72 -0
  4. fiberpath/config/validator.py +31 -0
  5. fiberpath/execution/__init__.py +7 -0
  6. fiberpath/execution/marlin.py +311 -0
  7. fiberpath/gcode/__init__.py +6 -0
  8. fiberpath/gcode/dialects.py +47 -0
  9. fiberpath/gcode/generator.py +31 -0
  10. fiberpath/geometry/__init__.py +13 -0
  11. fiberpath/geometry/curves.py +16 -0
  12. fiberpath/geometry/intersections.py +23 -0
  13. fiberpath/geometry/surfaces.py +17 -0
  14. fiberpath/math_utils.py +20 -0
  15. fiberpath/planning/__init__.py +13 -0
  16. fiberpath/planning/calculations.py +50 -0
  17. fiberpath/planning/exceptions.py +19 -0
  18. fiberpath/planning/helpers.py +55 -0
  19. fiberpath/planning/layer_strategies.py +194 -0
  20. fiberpath/planning/machine.py +151 -0
  21. fiberpath/planning/planner.py +133 -0
  22. fiberpath/planning/validators.py +51 -0
  23. fiberpath/simulation/__init__.py +5 -0
  24. fiberpath/simulation/simulator.py +186 -0
  25. fiberpath/visualization/__init__.py +13 -0
  26. fiberpath/visualization/export_json.py +14 -0
  27. fiberpath/visualization/plotter.py +261 -0
  28. fiberpath-0.3.0.dist-info/METADATA +827 -0
  29. fiberpath-0.3.0.dist-info/RECORD +49 -0
  30. fiberpath-0.3.0.dist-info/WHEEL +4 -0
  31. fiberpath-0.3.0.dist-info/entry_points.txt +2 -0
  32. fiberpath-0.3.0.dist-info/licenses/LICENSE +661 -0
  33. fiberpath_api/__init__.py +5 -0
  34. fiberpath_api/main.py +19 -0
  35. fiberpath_api/routes/__init__.py +1 -0
  36. fiberpath_api/routes/plan.py +41 -0
  37. fiberpath_api/routes/simulate.py +29 -0
  38. fiberpath_api/routes/stream.py +49 -0
  39. fiberpath_api/routes/validate.py +21 -0
  40. fiberpath_api/schemas.py +50 -0
  41. fiberpath_cli/__init__.py +5 -0
  42. fiberpath_cli/__main__.py +6 -0
  43. fiberpath_cli/main.py +27 -0
  44. fiberpath_cli/output.py +14 -0
  45. fiberpath_cli/plan.py +100 -0
  46. fiberpath_cli/plot.py +37 -0
  47. fiberpath_cli/simulate.py +36 -0
  48. fiberpath_cli/stream.py +123 -0
  49. fiberpath_cli/validate.py +26 -0
fiberpath_api/main.py ADDED
@@ -0,0 +1,19 @@
1
+ """Entry point for the FiberPath FastAPI service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import FastAPI
6
+
7
+ from .routes import plan, simulate, stream, validate
8
+
9
+
10
+ def create_app() -> FastAPI:
11
+ application = FastAPI(title="FiberPath API", version="0.2.0")
12
+ application.include_router(plan.router, prefix="/plan", tags=["planning"])
13
+ application.include_router(simulate.router, prefix="/simulate", tags=["simulation"])
14
+ application.include_router(validate.router, prefix="/validate", tags=["validation"])
15
+ application.include_router(stream.router, prefix="/stream", tags=["stream"])
16
+ return application
17
+
18
+
19
+ app = create_app()
@@ -0,0 +1 @@
1
+ """Route modules for the FiberPath API."""
@@ -0,0 +1,41 @@
1
+ """Planning endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from fiberpath.config import WindFileError, load_wind_definition
10
+ from fiberpath.gcode import write_gcode
11
+ from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
12
+ from fiberpath.planning import PlanOptions, plan_wind
13
+
14
+ from ..schemas import FilePathRequest, PlanLayer, PlanResponse
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.post("/from-file", response_model=PlanResponse)
20
+ def plan_from_file(payload: FilePathRequest) -> PlanResponse:
21
+ file_path = Path(payload.path)
22
+ try:
23
+ definition = load_wind_definition(file_path)
24
+ except WindFileError as exc: # pragma: no cover - HTTP glue
25
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
26
+
27
+ # Select dialect based on axis format
28
+ dialect = MARLIN_XAB_STANDARD if payload.axis_format == "xab" else MARLIN_XYZ_LEGACY
29
+ options = PlanOptions(verbose=payload.verbose, dialect=dialect)
30
+
31
+ result = plan_wind(definition, options)
32
+ temp_file = write_gcode(result.commands, file_path.with_suffix(".gcode"))
33
+ layers = [PlanLayer(**asdict(metric)) for metric in result.layers]
34
+ return PlanResponse(
35
+ commands=len(result.commands),
36
+ output=str(temp_file),
37
+ timeSeconds=result.total_time_s,
38
+ towMeters=result.total_tow_m,
39
+ axisFormat=payload.axis_format,
40
+ layers=layers,
41
+ )
@@ -0,0 +1,29 @@
1
+ """Simulation endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from fiberpath.simulation import simulate_program
9
+
10
+ from ..schemas import FilePathRequest, SimulationResponse
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/from-file", response_model=SimulationResponse)
16
+ def simulate_from_file(payload: FilePathRequest) -> SimulationResponse:
17
+ target = Path(payload.path)
18
+ if not target.exists():
19
+ raise HTTPException(status_code=404, detail=f"No file found at {payload.path}")
20
+ commands = target.read_text(encoding="utf-8").splitlines()
21
+ result = simulate_program(commands)
22
+ return SimulationResponse(
23
+ commands=result.commands_executed,
24
+ moves=result.moves,
25
+ estimated_time_s=result.estimated_time_s,
26
+ total_distance_mm=result.total_distance_mm,
27
+ tow_length_mm=result.tow_length_mm,
28
+ average_feed_rate_mmpm=result.average_feed_rate_mmpm,
29
+ )
@@ -0,0 +1,49 @@
1
+ """Serial streaming endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fiberpath.execution import MarlinStreamer, StreamError
7
+ from pydantic import BaseModel, Field
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ class StreamRequest(BaseModel):
13
+ gcode: str = Field(..., description="Raw G-code to stream, newline separated.")
14
+ port: str | None = Field(
15
+ default=None,
16
+ description="Serial port or pyserial URL. Required when dry_run is false.",
17
+ )
18
+ baud_rate: int = Field(default=250_000, ge=1, description="Controller baud rate.")
19
+ dry_run: bool = Field(default=True, description="Skip serial I/O and only count commands.")
20
+
21
+
22
+ class StreamResponse(BaseModel):
23
+ commands_streamed: int
24
+ total_commands: int
25
+ dry_run: bool
26
+
27
+
28
+ @router.post("/", response_model=StreamResponse)
29
+ def start_stream(payload: StreamRequest) -> StreamResponse:
30
+ if not payload.dry_run and payload.port is None:
31
+ raise HTTPException(status_code=400, detail="port is required when dry_run is false")
32
+
33
+ commands = payload.gcode.splitlines()
34
+ streamer = MarlinStreamer(port=payload.port, baud_rate=payload.baud_rate)
35
+ streamer.load_program(commands)
36
+
37
+ try:
38
+ for _update in streamer.iter_stream(dry_run=payload.dry_run):
39
+ pass
40
+ except StreamError as exc: # pragma: no cover - exercised via API test
41
+ raise HTTPException(status_code=502, detail=f"Streaming failed: {exc}") from exc
42
+ finally:
43
+ streamer.close()
44
+
45
+ return StreamResponse(
46
+ commands_streamed=streamer.commands_sent,
47
+ total_commands=streamer.commands_total,
48
+ dry_run=payload.dry_run,
49
+ )
@@ -0,0 +1,21 @@
1
+ """Validation endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from fiberpath.config import WindFileError, load_wind_definition
9
+
10
+ from ..schemas import FilePathRequest, ValidateResponse
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/from-file", response_model=ValidateResponse)
16
+ def validate_from_file(payload: FilePathRequest) -> ValidateResponse:
17
+ try:
18
+ load_wind_definition(Path(payload.path))
19
+ except WindFileError as exc: # pragma: no cover - HTTP glue
20
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
21
+ return ValidateResponse(status="ok", path=payload.path)
@@ -0,0 +1,50 @@
1
+ """Pydantic schemas shared by API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class FilePathRequest(BaseModel):
11
+ path: str = Field(..., description="Absolute or workspace path to an input file.")
12
+ axis_format: Literal["xyz", "xab"] = Field(
13
+ default="xab",
14
+ description="Axis coordinate format: xyz (legacy) or xab (standard rotational)",
15
+ )
16
+ verbose: bool = Field(default=False, description="Emit verbose planner output")
17
+
18
+
19
+ class PlanLayer(BaseModel):
20
+ index: int
21
+ wind_type: str
22
+ commands: int
23
+ time_s: float
24
+ cumulative_time_s: float
25
+ tow_m: float
26
+ cumulative_tow_m: float
27
+ terminal: bool
28
+
29
+
30
+ class PlanResponse(BaseModel):
31
+ commands: int
32
+ output: str
33
+ timeSeconds: float
34
+ towMeters: float
35
+ axisFormat: str
36
+ layers: list[PlanLayer]
37
+
38
+
39
+ class SimulationResponse(BaseModel):
40
+ commands: int
41
+ moves: int
42
+ estimated_time_s: float
43
+ total_distance_mm: float
44
+ tow_length_mm: float
45
+ average_feed_rate_mmpm: float
46
+
47
+
48
+ class ValidateResponse(BaseModel):
49
+ status: str
50
+ path: str
@@ -0,0 +1,5 @@
1
+ """CLI entry-points built on Typer."""
2
+
3
+ from .main import app
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,6 @@
1
+ """Allow running fiberpath_cli as a module with `python -m fiberpath_cli`."""
2
+
3
+ from .main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
fiberpath_cli/main.py ADDED
@@ -0,0 +1,27 @@
1
+ """FiberPath command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .plan import plan_command
8
+ from .plot import plot_command
9
+ from .simulate import simulate_command
10
+ from .stream import stream_command
11
+ from .validate import validate_command
12
+
13
+ app = typer.Typer(
14
+ name="FiberPath",
15
+ help="FiberPath utilities for planning and executing filament winding jobs.",
16
+ )
17
+
18
+
19
+ app.command("plan")(plan_command)
20
+ app.command("plot")(plot_command)
21
+ app.command("simulate")(simulate_command)
22
+ app.command("validate")(validate_command)
23
+ app.command("stream")(stream_command)
24
+
25
+
26
+ if __name__ == "__main__": # pragma: no cover
27
+ app()
@@ -0,0 +1,14 @@
1
+ """Utilities for CLI output modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+
11
+ def echo_json(payload: Any) -> None:
12
+ """Pretty-print payload as JSON."""
13
+
14
+ typer.echo(json.dumps(payload, indent=2))
fiberpath_cli/plan.py ADDED
@@ -0,0 +1,100 @@
1
+ """CLI plan command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from fiberpath.config import WindFileError, load_wind_definition
10
+ from fiberpath.gcode import write_gcode
11
+ from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD, MARLIN_XYZ_LEGACY
12
+ from fiberpath.planning import PlanOptions, plan_wind
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from .output import echo_json
17
+
18
+ console = Console()
19
+
20
+ WIND_FILE_ARGUMENT = typer.Argument(..., exists=True, readable=True, help="Input .wind file")
21
+ OUTPUT_OPTION = typer.Option(
22
+ Path("output.gcode"), "--output", "-o", help="Destination for generated G-code"
23
+ )
24
+ VERBOSE_OPTION = typer.Option(False, "--verbose", "-v", help="Emit verbose planner output")
25
+ JSON_OPTION = typer.Option(
26
+ False,
27
+ "--json",
28
+ help="Emit machine-readable JSON instead of human-readable text.",
29
+ )
30
+ AXIS_FORMAT_OPTION = typer.Option(
31
+ "xab",
32
+ "--axis-format",
33
+ help="Axis coordinate format: xyz (legacy) or xab (standard rotational)",
34
+ )
35
+
36
+
37
+ def plan_command(
38
+ wind_file: Path = WIND_FILE_ARGUMENT,
39
+ output: Path = OUTPUT_OPTION,
40
+ verbose: bool = VERBOSE_OPTION,
41
+ json_output: bool = JSON_OPTION,
42
+ axis_format: str = AXIS_FORMAT_OPTION,
43
+ ) -> None:
44
+ # Validate axis format
45
+ if axis_format not in {"xyz", "xab"}:
46
+ typer.echo(f"Invalid axis format: {axis_format}. Must be 'xyz' or 'xab'.", err=True)
47
+ raise typer.Exit(code=1)
48
+
49
+ # Select dialect based on axis format
50
+ dialect = MARLIN_XAB_STANDARD if axis_format == "xab" else MARLIN_XYZ_LEGACY
51
+
52
+ try:
53
+ wind_definition = load_wind_definition(wind_file)
54
+ except WindFileError as exc: # pragma: no cover - CLI glue
55
+ raise typer.BadParameter(str(exc)) from exc
56
+
57
+ try:
58
+ result = plan_wind(wind_definition, PlanOptions(verbose=verbose, dialect=dialect))
59
+ except Exception as exc: # pragma: no cover - defensive guard
60
+ typer.echo(f"Planning failed: {exc}", err=True)
61
+ raise typer.Exit(code=1) from exc
62
+
63
+ destination = write_gcode(result.commands, output)
64
+
65
+ summary = {
66
+ "output": str(destination),
67
+ "commands": len(result.commands),
68
+ "timeSeconds": result.total_time_s,
69
+ "towMeters": result.total_tow_m,
70
+ "axisFormat": axis_format,
71
+ "layers": [asdict(metric) for metric in result.layers],
72
+ }
73
+
74
+ if json_output:
75
+ echo_json(summary)
76
+ return
77
+
78
+ console.print(f"[green]Wrote[/green] {summary['commands']} commands to {destination}")
79
+ console.print(f"[dim]Axis format: {axis_format.upper()}[/dim]")
80
+
81
+ if verbose:
82
+ table = Table(title="Layer metrics", expand=False)
83
+ table.add_column("#", justify="right")
84
+ table.add_column("Type")
85
+ table.add_column("Cmds", justify="right")
86
+ table.add_column("Δt (s)", justify="right")
87
+ table.add_column("Tow (m)", justify="right")
88
+ for metric in result.layers:
89
+ table.add_row(
90
+ str(metric.index),
91
+ metric.wind_type,
92
+ str(metric.commands),
93
+ f"{metric.time_s:.2f}",
94
+ f"{metric.tow_m:.3f}",
95
+ )
96
+ console.print(table)
97
+ console.print(
98
+ f"[cyan]Totals[/cyan] time={result.total_time_s:.2f}s tow={result.total_tow_m:.3f}m"
99
+ )
100
+ console.print(wind_definition.model_dump(mode="json"))
fiberpath_cli/plot.py ADDED
@@ -0,0 +1,37 @@
1
+ """CLI plot command for PNG previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from fiberpath.visualization.plotter import PlotConfig, PlotError, render_plot
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+ GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True, help="Input G-code file")
14
+ OUTPUT_OPTION = typer.Option(Path("plot.png"), "--output", "-o", help="PNG destination")
15
+ SCALE_OPTION = typer.Option(
16
+ 1.0, "--scale", help="Pixels per millimeter along carriage axis", min=0.1, max=5.0
17
+ )
18
+
19
+
20
+ def plot_command(
21
+ gcode_file: Path = GCODE_ARGUMENT,
22
+ output: Path = OUTPUT_OPTION,
23
+ scale: float = SCALE_OPTION,
24
+ ) -> None:
25
+ program = gcode_file.read_text(encoding="utf-8").splitlines()
26
+ try:
27
+ result = render_plot(program, PlotConfig(scale=scale))
28
+ except PlotError as exc: # pragma: no cover - parameter validation
29
+ raise typer.BadParameter(str(exc)) from exc
30
+
31
+ output.parent.mkdir(parents=True, exist_ok=True)
32
+ result.image.save(output, format="PNG")
33
+ console.print(
34
+ "[green]Rendered[/green] preview to",
35
+ output,
36
+ f"({result.metadata.mandrel_length_mm:.1f} mm × {result.metadata.tow_width_mm:.1f} mm tow)",
37
+ )
@@ -0,0 +1,36 @@
1
+ """CLI simulate command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from fiberpath.simulation import SimulationError, simulate_program
10
+
11
+ from .output import echo_json
12
+
13
+ GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
14
+ JSON_OPTION = typer.Option(False, "--json", help="Emit machine-readable JSON summary")
15
+
16
+
17
+ def simulate_command(gcode_file: Path = GCODE_ARGUMENT, json_output: bool = JSON_OPTION) -> None:
18
+ commands = Path(gcode_file).read_text(encoding="utf-8").splitlines()
19
+ try:
20
+ result = simulate_program(commands)
21
+ except SimulationError as exc:
22
+ typer.echo(f"Simulation failed: {exc}", err=True)
23
+ raise typer.Exit(code=1) from exc
24
+
25
+ if json_output:
26
+ echo_json(asdict(result))
27
+ return
28
+
29
+ typer.echo(
30
+ "Simulated "
31
+ f"{result.commands_executed} commands / {result.moves} moves in "
32
+ f"{result.estimated_time_s:.2f}s\n"
33
+ f" distance: {result.total_distance_mm:.1f} mm"
34
+ f" tow: {result.tow_length_mm / 1000.0:.3f} m"
35
+ f" avg feed: {result.average_feed_rate_mmpm:.0f} mm/min"
36
+ )
@@ -0,0 +1,123 @@
1
+ """CLI command for streaming G-code to a Marlin controller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from fiberpath.execution import MarlinStreamer, StreamError, StreamProgress
9
+
10
+ from .output import echo_json
11
+
12
+ GCODE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
13
+ PROGRESS_INTERVAL = 25
14
+
15
+
16
+ def stream_command(
17
+ gcode_file: Path = GCODE_ARGUMENT,
18
+ port: str | None = typer.Option(
19
+ None,
20
+ "--port",
21
+ "-p",
22
+ help="Serial port or pyserial URL (required unless --dry-run).",
23
+ ),
24
+ baud_rate: int = typer.Option(250_000, "--baud-rate", "-b", help="Marlin baud rate."),
25
+ response_timeout: float = typer.Option(
26
+ 10.0,
27
+ "--timeout",
28
+ "-t",
29
+ help="Response timeout in seconds for slow moves.",
30
+ ),
31
+ dry_run: bool = typer.Option(
32
+ False,
33
+ "--dry-run",
34
+ help="Skip serial I/O and just report what would be streamed.",
35
+ ),
36
+ verbose: bool = typer.Option(False, "--verbose", help="Print every streamed command."),
37
+ json_output: bool = typer.Option(
38
+ False,
39
+ "--json",
40
+ help="Emit final summary as JSON (progress lines suppressed).",
41
+ ),
42
+ ) -> None:
43
+ """Stream the provided G-code file to a Marlin device."""
44
+
45
+ if not dry_run and port is None:
46
+ raise typer.BadParameter("--port is required for live streaming", param_hint="--port")
47
+
48
+ commands = gcode_file.read_text(encoding="utf-8").splitlines()
49
+ log_callback = None if json_output else (typer.echo if verbose else None)
50
+ streamer = MarlinStreamer(
51
+ port=port, baud_rate=baud_rate, response_timeout_s=response_timeout, log=log_callback
52
+ )
53
+ streamer.load_program(commands)
54
+
55
+ progress_verbose = verbose or dry_run
56
+
57
+ try:
58
+ while streamer.commands_sent < streamer.commands_total:
59
+ try:
60
+ for update in streamer.iter_stream(dry_run=dry_run):
61
+ if not json_output and _should_print_progress(update, verbose=progress_verbose):
62
+ typer.echo(_format_progress(update))
63
+ break
64
+ except KeyboardInterrupt: # pragma: no cover - user-driven flow
65
+ if dry_run:
66
+ raise
67
+ typer.echo("\nPause requested (Ctrl+C). Sending M0 ...")
68
+ _pause_and_prompt(streamer)
69
+ continue
70
+ except StreamError as exc:
71
+ typer.echo(f"Streaming failed: {exc}", err=True)
72
+ raise typer.Exit(code=1) from exc
73
+ finally:
74
+ streamer.close()
75
+
76
+ summary = {
77
+ "status": "dry-run" if dry_run else "live",
78
+ "commands": streamer.commands_sent,
79
+ "total": streamer.commands_total,
80
+ "baudRate": baud_rate,
81
+ "dryRun": dry_run,
82
+ }
83
+
84
+ if json_output:
85
+ echo_json(summary)
86
+ return
87
+
88
+ status = "Dry-run" if dry_run else "Streamed"
89
+ typer.echo(
90
+ f"{status} {streamer.commands_sent}/{streamer.commands_total} commands at {baud_rate} baud."
91
+ )
92
+
93
+
94
+ def _should_print_progress(progress: StreamProgress, *, verbose: bool) -> bool:
95
+ if verbose:
96
+ return True
97
+ if progress.commands_sent in {1, progress.commands_total}:
98
+ return True
99
+ if progress.commands_total <= PROGRESS_INTERVAL:
100
+ return False
101
+ return progress.commands_sent % PROGRESS_INTERVAL == 0
102
+
103
+
104
+ def _format_progress(progress: StreamProgress) -> str:
105
+ phase = "dry-run" if progress.dry_run else "live"
106
+ return f"[{progress.commands_sent}/{progress.commands_total}] ({phase}) {progress.command}"
107
+
108
+
109
+ def _pause_and_prompt(streamer: MarlinStreamer) -> None:
110
+ try:
111
+ streamer.pause()
112
+ except StreamError as exc:
113
+ raise typer.Exit(code=1) from exc
114
+
115
+ if not typer.confirm("Resume streaming?", default=True):
116
+ typer.echo("Stopping stream without resuming.")
117
+ raise typer.Exit(code=0)
118
+
119
+ typer.echo("Resuming (sending M108)...")
120
+ try:
121
+ streamer.resume()
122
+ except StreamError as exc:
123
+ raise typer.Exit(code=1) from exc
@@ -0,0 +1,26 @@
1
+ """CLI validate command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from fiberpath.config import WindFileError, load_wind_definition
9
+
10
+ from .output import echo_json
11
+
12
+ WIND_FILE_ARGUMENT = typer.Argument(..., exists=True, readable=True)
13
+ JSON_OPTION = typer.Option(False, "--json", help="Emit machine-readable JSON summary")
14
+
15
+
16
+ def validate_command(wind_file: Path = WIND_FILE_ARGUMENT, json_output: bool = JSON_OPTION) -> None:
17
+ try:
18
+ load_wind_definition(wind_file)
19
+ except WindFileError as exc: # pragma: no cover - CLI glue
20
+ raise typer.BadParameter(str(exc)) from exc
21
+
22
+ if json_output:
23
+ echo_json({"status": "ok", "path": str(wind_file)})
24
+ return
25
+
26
+ typer.echo(f"{wind_file} is valid.")