From 445c8dd7fbbc00c0db408160330f3d43def94f51 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:20:27 -0800 Subject: [PATCH 01/11] feat(api): add REST API integration with web dashboard --- docs/api/integrations/api.md | 327 +++++++++++++++++++++- tests/integrations/test_api.py | 485 +++++++++++++++++++++++++++++++-- 2 files changed, 774 insertions(+), 38 deletions(-) diff --git a/docs/api/integrations/api.md b/docs/api/integrations/api.md index 025cb19..9061b63 100644 --- a/docs/api/integrations/api.md +++ b/docs/api/integrations/api.md @@ -14,7 +14,34 @@ pip install cnckit[api] from cnckit.integrations.api import create_app import uvicorn -app = create_app() +# Create app with simulated machine (safe default) +app = create_app(simulate=True) + +# Run the server +uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +### Using with Existing Components + +```python +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter +from cnckit.integrations.api import create_app +import uvicorn + +# Set up your components +machine = Machine(simulate=True) +queue = JobQueue() +events = EventEmitter() +scheduler = Scheduler(machine, queue, events) + +# Create API with your components +app = create_app( + machine=machine, + queue=queue, + scheduler=scheduler, + events=events, +) + uvicorn.run(app, host="0.0.0.0", port=8000) ``` @@ -27,18 +54,294 @@ uvicorn.run(app, host="0.0.0.0", port=8000) ## Endpoints -!!! note "Coming in Phase 2" - The REST API will be implemented in Phase 2. +### Dashboard + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/dashboard` | Web dashboard for monitoring and control | + +The dashboard provides a real-time view of: + +- **Machine Status**: Position (X, Y, Z), state, current tool, progress +- **Scheduler Controls**: Start, pause, stop buttons +- **Job Queue**: List of pending jobs with status +- **Event Log**: Real-time events from WebSocket (if running) + +Access the dashboard at `http://localhost:8000/dashboard` when the server is running. + +### Health + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check - returns service status and timestamp | + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00.123456" +} +``` + +### Machine + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/machine/status` | Get current machine state, position, and program info | + +**Response:** +```json +{ + "state": "idle", + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "a": null, + "b": null, + "c": null + }, + "tool": 0, + "current_program": null, + "progress": 0.0, + "simulate": true +} +``` + +**Machine States:** + +- `disconnected` - Not connected to controller +- `idle` - Ready to run +- `running` - Program executing +- `paused` - Program paused mid-execution +- `error` - Error state, needs intervention +- `estop` - Emergency stop activated + +### Queue + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/queue` | List all queued jobs | +| POST | `/queue` | Add a job to the queue | +| DELETE | `/queue/{job_id}` | Remove a job from the queue | + +**GET /queue Response:** +```json +{ + "mode": "fifo", + "count": 2, + "jobs": [ + { + "id": "/path/to/part1.ngc", + "name": "part1", + "path": "/path/to/part1.ngc", + "priority": 0, + "status": "pending", + "estimated_time": null, + "created_at": "2024-01-15T10:30:00.123456", + "started_at": null, + "completed_at": null, + "error": null + } + ] +} +``` + +**POST /queue Request:** +```json +{ + "path": "/path/to/part.ngc", + "priority": 5, + "name": "My Custom Job Name" +} +``` + +**POST /queue Response (201 Created):** +```json +{ + "message": "Job added to queue", + "job": { + "id": "/path/to/part.ngc", + "name": "My Custom Job Name", + "path": "/path/to/part.ngc", + "priority": 5, + "status": "pending", + "estimated_time": null, + "created_at": "2024-01-15T10:30:00.123456", + "started_at": null, + "completed_at": null, + "error": null + } +} +``` + +**Job Statuses:** + +- `pending` - Waiting in queue +- `running` - Currently executing +- `completed` - Finished successfully +- `failed` - Finished with error -Planned endpoints: +### Scheduler | Method | Path | Description | |--------|------|-------------| -| GET | `/health` | Health check | -| GET | `/machine/status` | Machine state | -| GET | `/queue` | List queued jobs | -| POST | `/queue` | Add a job | -| DELETE | `/queue/{job_id}` | Remove a job | -| POST | `/scheduler/start` | Start scheduler | -| POST | `/scheduler/pause` | Pause scheduler | -| POST | `/scheduler/stop` | Stop scheduler | +| GET | `/scheduler/status` | Get scheduler state and current job | +| POST | `/scheduler/start` | Start processing jobs | +| POST | `/scheduler/pause` | Pause after current job | +| POST | `/scheduler/stop` | Stop immediately | +| POST | `/scheduler/tick` | Manually trigger a scheduler tick | + +**GET /scheduler/status Response:** +```json +{ + "state": "running", + "current_job": { + "id": "/path/to/part.ngc", + "name": "part", + "path": "/path/to/part.ngc", + "priority": 0, + "status": "running", + "estimated_time": null, + "created_at": "2024-01-15T10:30:00.123456", + "started_at": "2024-01-15T10:31:00.123456", + "completed_at": null, + "error": null + } +} +``` + +**Scheduler States:** + +- `stopped` - Not processing jobs +- `running` - Actively processing queue +- `paused` - Paused, will not start next job + +## Error Responses + +All errors return a JSON response with a `detail` field: + +```json +{ + "detail": "G-code file not found: /path/to/missing.ngc" +} +``` + +**HTTP Status Codes:** + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created (new resource) | +| 400 | Bad request | +| 404 | Resource not found | +| 500 | Internal server error | +| 503 | Service unavailable | + +## Interactive Documentation + +When the server is running, visit: + +- **Swagger UI**: `http://localhost:8000/docs` +- **ReDoc**: `http://localhost:8000/redoc` +- **OpenAPI Schema**: `http://localhost:8000/openapi.json` + +## Example: Complete Workflow + +```python +import requests + +BASE_URL = "http://localhost:8000" + +# Check health +response = requests.get(f"{BASE_URL}/health") +print(response.json()) + +# Add jobs to queue +for gcode_file in ["part1.ngc", "part2.ngc", "part3.ngc"]: + response = requests.post( + f"{BASE_URL}/queue", + json={"path": f"/jobs/{gcode_file}", "priority": 0} + ) + print(f"Added: {response.json()['job']['name']}") + +# Check queue +response = requests.get(f"{BASE_URL}/queue") +print(f"Queue has {response.json()['count']} jobs") + +# Start scheduler +requests.post(f"{BASE_URL}/scheduler/start") + +# Monitor progress +while True: + status = requests.get(f"{BASE_URL}/scheduler/status").json() + if status["state"] == "stopped": + print("All jobs completed!") + break + + if status["current_job"]: + machine = requests.get(f"{BASE_URL}/machine/status").json() + print(f"Running: {status['current_job']['name']} - {machine['progress']*100:.1f}%") + + time.sleep(1) +``` + +## Pydantic Models + +The API uses Pydantic models for request/response validation. These are exported for use in type hints: + +```python +from cnckit.integrations.api import ( + HealthResponse, + MachineStatusResponse, + PositionResponse, + JobResponse, + QueueResponse, + AddJobRequest, + AddJobResponse, + SchedulerStatusResponse, + MessageResponse, + ErrorResponse, +) +``` + +## Using Dashboard with WebSocket + +For the best experience, run both the REST API and WebSocket server together: + +```python +import asyncio +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter +from cnckit.integrations.api import create_app +from cnckit.integrations.websocket import WebSocketServer +import uvicorn + +async def main(): + # Set up components + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + + # Start WebSocket server for real-time updates + ws_server = WebSocketServer(port=8765) + ws_server.bind_events(events) + + # Create REST API + app = create_app( + machine=machine, + queue=queue, + scheduler=scheduler, + events=events, + ) + + async with ws_server: + # Run REST API (blocks) + config = uvicorn.Config(app, host="0.0.0.0", port=8000) + server = uvicorn.Server(config) + await server.serve() + +asyncio.run(main()) +``` + +Then open `http://localhost:8000/dashboard` for real-time monitoring with both REST API controls and WebSocket event updates. diff --git a/tests/integrations/test_api.py b/tests/integrations/test_api.py index b225cff..2d38abb 100644 --- a/tests/integrations/test_api.py +++ b/tests/integrations/test_api.py @@ -2,33 +2,466 @@ import pytest +# Check if fastapi is available for testing +try: + from fastapi.testclient import TestClient -class TestAPIIntegration: - """Tests for FastAPI integration.""" + FASTAPI_AVAILABLE = True +except ImportError: + FASTAPI_AVAILABLE = False - def test_create_app_raises_without_fastapi(self): - """create_app should raise ImportError if fastapi not installed.""" - # This test will pass in CI without [api] extras - # and be skipped when fastapi is available - try: - import fastapi # noqa: F401 +class TestAPIWithoutFastAPI: + """Tests that work without FastAPI installed.""" + + def test_import_raises_without_fastapi(self): + """Import should raise ImportError if fastapi not installed.""" + if FASTAPI_AVAILABLE: pytest.skip("fastapi is installed, cannot test ImportError") - except ImportError: - from cnckit.integrations.api import create_app - - with pytest.raises(ImportError, match="pip install cnckit\\[api\\]"): - create_app() - - # === Tests to be enabled in Phase 2 === - # - # @pytest.mark.integration - # def test_app_has_health_endpoint(self): - # """API should have a /health endpoint.""" - # from cnckit.integrations.api import create_app - # from fastapi.testclient import TestClient - # - # app = create_app() - # client = TestClient(app) - # response = client.get("/health") - # assert response.status_code == 200 + + with pytest.raises(ImportError, match="pip install cnckit\\[api\\]"): + from cnckit.integrations.api import create_app # noqa: F401 + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestHealthEndpoint: + """Tests for /health endpoint.""" + + def test_health_returns_200(self): + """Health endpoint returns 200 OK.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "timestamp" in data + + def test_health_includes_iso_timestamp(self): + """Health response includes ISO format timestamp.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/health") + data = response.json() + + # Should be valid ISO format + assert "T" in data["timestamp"] + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestDashboardEndpoint: + """Tests for /dashboard endpoint.""" + + def test_dashboard_returns_200(self): + """Dashboard endpoint returns 200 OK.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/dashboard") + + assert response.status_code == 200 + + def test_dashboard_returns_html(self): + """Dashboard returns HTML content.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/dashboard") + + assert "text/html" in response.headers["content-type"] + assert "" in response.text + + def test_dashboard_contains_cnckit_title(self): + """Dashboard HTML contains CNCKit title.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/dashboard") + + assert "CNCKit Dashboard" in response.text + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestMachineEndpoint: + """Tests for /machine/status endpoint.""" + + def test_machine_status_returns_200(self): + """Machine status endpoint returns 200 OK.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/machine/status") + + assert response.status_code == 200 + + def test_machine_status_includes_state(self): + """Machine status includes state field.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/machine/status") + data = response.json() + + assert "state" in data + assert data["state"] == "idle" # Simulated machine starts idle + + def test_machine_status_includes_position(self): + """Machine status includes position with x, y, z coordinates.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/machine/status") + data = response.json() + + assert "position" in data + pos = data["position"] + assert "x" in pos + assert "y" in pos + assert "z" in pos + + def test_machine_status_includes_simulate_flag(self): + """Machine status includes simulate flag.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/machine/status") + data = response.json() + + assert data["simulate"] is True + + def test_machine_status_with_custom_machine(self): + """Machine status works with custom machine instance.""" + from cnckit.core import Machine + from cnckit.integrations.api import create_app + + machine = Machine(simulate=True) + app = create_app(machine=machine) + client = TestClient(app) + + response = client.get("/machine/status") + data = response.json() + + assert data["state"] == "idle" + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestQueueEndpoints: + """Tests for /queue endpoints.""" + + def test_get_queue_empty(self): + """Empty queue returns correctly.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/queue") + + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "fifo" + assert data["count"] == 0 + assert data["jobs"] == [] + + def test_add_job_success(self, sample_gcode_file): + """Adding a valid job returns 201.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.post( + "/queue", + json={"path": sample_gcode_file, "priority": 5}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["message"] == "Job added to queue" + assert data["job"]["path"] == sample_gcode_file + assert data["job"]["priority"] == 5 + + def test_add_job_file_not_found(self): + """Adding nonexistent file returns 404.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.post( + "/queue", + json={"path": "/nonexistent/file.ngc"}, + ) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_add_job_with_name(self, sample_gcode_file): + """Adding job with custom name sets the name.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.post( + "/queue", + json={"path": sample_gcode_file, "name": "My Custom Job"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["job"]["name"] == "My Custom Job" + + def test_get_queue_with_jobs(self, sample_gcode_file): + """Queue with jobs returns all jobs.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Add two jobs + client.post("/queue", json={"path": sample_gcode_file, "priority": 1}) + client.post("/queue", json={"path": sample_gcode_file, "priority": 2}) + + response = client.get("/queue") + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 2 + assert len(data["jobs"]) == 2 + + def test_remove_job_success(self, sample_gcode_file): + """Removing existing job returns success.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Add a job + add_response = client.post( + "/queue", + json={"path": sample_gcode_file}, + ) + job_id = add_response.json()["job"]["id"] + + # Remove it + response = client.delete(f"/queue/{job_id}") + + assert response.status_code == 200 + assert "removed" in response.json()["message"].lower() + + # Verify queue is empty + queue_response = client.get("/queue") + assert queue_response.json()["count"] == 0 + + def test_remove_job_not_found(self): + """Removing nonexistent job returns 404.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.delete("/queue/nonexistent.ngc") + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestSchedulerEndpoints: + """Tests for /scheduler endpoints.""" + + def test_get_scheduler_status(self): + """Scheduler status returns correctly.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/scheduler/status") + + assert response.status_code == 200 + data = response.json() + assert data["state"] == "stopped" # Default state + assert data["current_job"] is None + + def test_start_scheduler(self): + """Starting scheduler changes state.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.post("/scheduler/start") + + assert response.status_code == 200 + assert "started" in response.json()["message"].lower() + + def test_pause_scheduler(self): + """Pausing scheduler returns success message.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Start first + client.post("/scheduler/start") + + response = client.post("/scheduler/pause") + + assert response.status_code == 200 + assert "paus" in response.json()["message"].lower() + + def test_stop_scheduler(self): + """Stopping scheduler changes state to stopped.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Start first + client.post("/scheduler/start") + + response = client.post("/scheduler/stop") + + assert response.status_code == 200 + assert "stopped" in response.json()["message"].lower() + + # Verify state + status_response = client.get("/scheduler/status") + assert status_response.json()["state"] == "stopped" + + def test_tick_scheduler(self, sample_gcode_file): + """Tick scheduler advances state.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Add a job and start scheduler + client.post("/queue", json={"path": sample_gcode_file}) + client.post("/scheduler/start") + + response = client.post("/scheduler/tick") + + assert response.status_code == 200 + data = response.json() + assert "state" in data + + def test_scheduler_runs_job(self, sample_gcode_file): + """Scheduler executes a job through ticks.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Add a job + client.post("/queue", json={"path": sample_gcode_file}) + + # Start scheduler + client.post("/scheduler/start") + + # Should have picked up the job + status = client.get("/scheduler/status").json() + assert status["current_job"] is not None + assert status["current_job"]["status"] == "running" + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestAppStateManagement: + """Tests for application state management.""" + + def test_create_app_with_defaults(self): + """create_app works with default arguments.""" + from cnckit.integrations.api import create_app + + app = create_app() + + assert app is not None + assert app.title == "CNCKit API" + + def test_create_app_with_custom_components(self): + """create_app accepts custom core components.""" + from cnckit.core import EventEmitter, JobQueue, Machine, Scheduler + from cnckit.integrations.api import create_app + + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + + app = create_app( + machine=machine, + queue=queue, + scheduler=scheduler, + events=events, + ) + + assert app is not None + + def test_state_shared_across_requests(self, sample_gcode_file): + """State is shared across multiple requests.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + # Add job via API + client.post("/queue", json={"path": sample_gcode_file}) + + # Verify via separate request + response = client.get("/queue") + assert response.json()["count"] == 1 + + +@pytest.mark.skipif(not FASTAPI_AVAILABLE, reason="FastAPI not installed") +class TestOpenAPIDocumentation: + """Tests for API documentation.""" + + def test_openapi_schema_available(self): + """OpenAPI schema is generated.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/openapi.json") + + assert response.status_code == 200 + schema = response.json() + assert schema["info"]["title"] == "CNCKit API" + assert "/health" in schema["paths"] + assert "/machine/status" in schema["paths"] + assert "/queue" in schema["paths"] + + def test_docs_available(self): + """Swagger UI docs are available.""" + from cnckit.integrations.api import create_app + + app = create_app(simulate=True) + client = TestClient(app) + + response = client.get("/docs") + + assert response.status_code == 200 From e2832d661f6fd2eb9e8e8a9aad23481cbfb575a0 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:21:03 -0800 Subject: [PATCH 02/11] fix: add api init --- src/cnckit/integrations/api/__init__.py | 546 +++++++++++++++++++++++- 1 file changed, 534 insertions(+), 12 deletions(-) diff --git a/src/cnckit/integrations/api/__init__.py b/src/cnckit/integrations/api/__init__.py index 7ae1525..21042be 100644 --- a/src/cnckit/integrations/api/__init__.py +++ b/src/cnckit/integrations/api/__init__.py @@ -5,25 +5,547 @@ Requires: pip install cnckit[api] """ -from typing import Any +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any -def create_app() -> Any: +# Validate FastAPI is available on import +try: + from fastapi import Depends, FastAPI, HTTPException, status + from fastapi.responses import HTMLResponse, JSONResponse + from pydantic import BaseModel, Field +except ImportError: + raise ImportError( + "FastAPI is required for the REST API integration. " + "Install with: pip install cnckit[api]" + ) from None + +# Path to static files +_STATIC_DIR = Path(__file__).parent / "static" + +if TYPE_CHECKING: + from cnckit.core import EventEmitter, JobQueue, Machine, Scheduler + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = Field(description="Service status") + timestamp: str = Field(description="ISO format timestamp") + + +class PositionResponse(BaseModel): + """Machine position response.""" + + x: float + y: float + z: float + a: float | None = None + b: float | None = None + c: float | None = None + + +class MachineStatusResponse(BaseModel): + """Machine status response.""" + + state: str = Field(description="Machine state (idle, running, etc.)") + position: PositionResponse + tool: int = Field(description="Current tool number") + current_program: str | None = Field(description="Path to loaded program") + progress: float = Field(description="Program progress (0.0 to 1.0)") + simulate: bool = Field(description="Whether machine is in simulation mode") + + +class JobResponse(BaseModel): + """Job information response.""" + + id: str = Field(description="Job identifier (path-based)") + name: str = Field(description="Human-readable job name") + path: str = Field(description="Path to G-code file") + priority: int = Field(description="Job priority") + status: str = Field(description="Job status") + estimated_time: float | None = Field(description="Estimated runtime in seconds") + created_at: str = Field(description="ISO format creation timestamp") + started_at: str | None = Field(description="ISO format start timestamp") + completed_at: str | None = Field(description="ISO format completion timestamp") + error: str | None = Field(description="Error message if failed") + + +class QueueResponse(BaseModel): + """Queue status response.""" + + mode: str = Field(description="Queue mode (fifo, lifo, priority)") + count: int = Field(description="Number of jobs in queue") + jobs: list[JobResponse] = Field(description="List of queued jobs") + + +class AddJobRequest(BaseModel): + """Request to add a job to the queue.""" + + path: str = Field(description="Path to G-code file") + priority: int = Field(default=0, description="Job priority (higher = more urgent)") + name: str | None = Field(default=None, description="Optional human-readable name") + + +class AddJobResponse(BaseModel): + """Response after adding a job.""" + + message: str + job: JobResponse + + +class SchedulerStatusResponse(BaseModel): + """Scheduler status response.""" + + state: str = Field(description="Scheduler state (stopped, running, paused)") + current_job: JobResponse | None = Field(description="Currently executing job") + + +class MessageResponse(BaseModel): + """Simple message response.""" + + message: str + + +class ErrorResponse(BaseModel): + """Error response.""" + + detail: str + + +# ============================================================================= +# Application State +# ============================================================================= + + +@dataclass +class AppState: + """ + Holds references to cnckit core components. + + This allows the API to interact with an existing machine/queue/scheduler + setup, or create new instances if none are provided. + """ + + machine: Machine | None = None + queue: JobQueue | None = None + scheduler: Scheduler | None = None + events: EventEmitter | None = None + _initialized: bool = field(default=False, init=False) + + def initialize( + self, + machine: Machine | None = None, + queue: JobQueue | None = None, + scheduler: Scheduler | None = None, + events: EventEmitter | None = None, + simulate: bool = True, + ) -> None: + """ + Initialize or update the application state. + + Args: + machine: Machine instance (creates simulated if not provided) + queue: JobQueue instance (creates new if not provided) + scheduler: Scheduler instance (creates new if not provided) + events: EventEmitter instance (creates new if not provided) + simulate: If creating new machine, use simulation mode + """ + from cnckit.core import EventEmitter as EE + from cnckit.core import JobQueue, Machine, Scheduler + + self.events = events if events is not None else EE() + self.machine = machine if machine is not None else Machine(simulate=simulate) + self.queue = queue if queue is not None else JobQueue() + self.scheduler = ( + scheduler + if scheduler is not None + else Scheduler(self.machine, self.queue, self.events) + ) + self._initialized = True + + @property + def is_initialized(self) -> bool: + """Check if the app state has been initialized.""" + return self._initialized + + +# Global app state - will be initialized when create_app() is called +_app_state = AppState() + + +def get_state() -> AppState: + """Dependency to get the application state.""" + if not _app_state.is_initialized: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Application not initialized. Call initialize() first.", + ) + return _app_state + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def job_to_response(job: Any) -> JobResponse: + """Convert a Job instance to a JobResponse.""" + return JobResponse( + id=str(job.path), + name=job.name or job.path.stem, + path=str(job.path), + priority=job.priority, + status=job.status.value, + estimated_time=job.estimated_time, + created_at=job.created_at.isoformat(), + started_at=job.started_at.isoformat() if job.started_at else None, + completed_at=job.completed_at.isoformat() if job.completed_at else None, + error=job.error, + ) + + +# ============================================================================= +# API Routes +# ============================================================================= + + +def create_app( + machine: Machine | None = None, + queue: JobQueue | None = None, + scheduler: Scheduler | None = None, + events: EventEmitter | None = None, + simulate: bool = True, +) -> FastAPI: """ Create a FastAPI application for cnckit. + Args: + machine: Optional Machine instance to use. If not provided, + creates a new one based on the simulate parameter. + queue: Optional JobQueue instance to use. If not provided, + creates a new FIFO queue. + scheduler: Optional Scheduler instance to use. If not provided, + creates a new scheduler with the machine and queue. + events: Optional EventEmitter instance to use for events. + simulate: If True and no machine provided, create a simulated machine. + Defaults to True for safety. + Returns: - FastAPI application instance + FastAPI application instance configured with all endpoints. + + Example: + >>> from cnckit.integrations.api import create_app + >>> import uvicorn + >>> + >>> app = create_app(simulate=True) + >>> uvicorn.run(app, host="0.0.0.0", port=8000) - Raises: - ImportError: If fastapi is not installed + Or with existing components: + >>> from cnckit.core import Machine, JobQueue, Scheduler + >>> machine = Machine(simulate=True) + >>> queue = JobQueue() + >>> scheduler = Scheduler(machine, queue) + >>> app = create_app(machine=machine, queue=queue, scheduler=scheduler) """ - try: - import fastapi # noqa: F401 - except ImportError: - raise ImportError( - "FastAPI is required for the REST API integration. " - "Install with: pip install cnckit[api]" + # Initialize the global app state + _app_state.initialize( + machine=machine, + queue=queue, + scheduler=scheduler, + events=events, + simulate=simulate, + ) + + app = FastAPI( + title="CNCKit API", + description="REST API for CNC machine monitoring and control", + version="0.1.0", + responses={ + 503: {"model": ErrorResponse, "description": "Service unavailable"}, + }, + ) + + # ------------------------------------------------------------------------- + # Health Endpoint + # ------------------------------------------------------------------------- + + @app.get( + "/health", + response_model=HealthResponse, + tags=["Health"], + summary="Health check", + description="Check if the API service is running.", + ) + def health() -> HealthResponse: + return HealthResponse( + status="healthy", + timestamp=datetime.now().isoformat(), + ) + + # ------------------------------------------------------------------------- + # Dashboard + # ------------------------------------------------------------------------- + + @app.get( + "/dashboard", + response_class=HTMLResponse, + tags=["Dashboard"], + summary="Web dashboard", + description="Simple web dashboard for monitoring and control.", + ) + def dashboard() -> HTMLResponse: + dashboard_path = _STATIC_DIR / "dashboard.html" + if not dashboard_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Dashboard not found", + ) + return HTMLResponse(content=dashboard_path.read_text()) + + # ------------------------------------------------------------------------- + # Machine Endpoints + # ------------------------------------------------------------------------- + + @app.get( + "/machine/status", + response_model=MachineStatusResponse, + tags=["Machine"], + summary="Get machine status", + description="Get the current machine state, position, and program info.", + ) + def get_machine_status( + state: AppState = Depends(get_state), + ) -> MachineStatusResponse: + machine = state.machine + assert machine is not None # Guaranteed by get_state + pos = machine.position + return MachineStatusResponse( + state=machine.state.value, + position=PositionResponse( + x=pos.x, + y=pos.y, + z=pos.z, + a=pos.a, + b=pos.b, + c=pos.c, + ), + tool=machine.tool, + current_program=machine.current_program, + progress=machine.progress, + simulate=machine.simulate, + ) + + # ------------------------------------------------------------------------- + # Queue Endpoints + # ------------------------------------------------------------------------- + + @app.get( + "/queue", + response_model=QueueResponse, + tags=["Queue"], + summary="List queued jobs", + description="Get all jobs currently in the queue.", + ) + def get_queue(state: AppState = Depends(get_state)) -> QueueResponse: + queue = state.queue + assert queue is not None + return QueueResponse( + mode=queue.mode.value, + count=len(queue), + jobs=[job_to_response(job) for job in queue.jobs()], + ) + + @app.post( + "/queue", + response_model=AddJobResponse, + status_code=status.HTTP_201_CREATED, + tags=["Queue"], + summary="Add a job", + description="Add a new job to the queue.", + responses={ + 400: {"model": ErrorResponse, "description": "Invalid request"}, + 404: {"model": ErrorResponse, "description": "File not found"}, + }, + ) + def add_job( + request: AddJobRequest, + state: AppState = Depends(get_state), + ) -> AddJobResponse: + queue = state.queue + assert queue is not None + + # Validate the file exists + path = Path(request.path) + if not path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"G-code file not found: {request.path}", + ) + + # Add to queue + job = queue.add( + path=request.path, + priority=request.priority, + name=request.name, + ) + + return AddJobResponse( + message="Job added to queue", + job=job_to_response(job), + ) + + @app.delete( + "/queue/{job_id:path}", + response_model=MessageResponse, + tags=["Queue"], + summary="Remove a job", + description="Remove a job from the queue by its ID (path).", + responses={ + 404: {"model": ErrorResponse, "description": "Job not found"}, + }, + ) + def remove_job( + job_id: str, + state: AppState = Depends(get_state), + ) -> MessageResponse: + queue = state.queue + assert queue is not None + + # Find the job by path + for job in queue.jobs(): + if str(job.path) == job_id: + queue.remove(job) + return MessageResponse(message=f"Job removed: {job_id}") + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job not found: {job_id}", + ) + + # ------------------------------------------------------------------------- + # Scheduler Endpoints + # ------------------------------------------------------------------------- + + @app.get( + "/scheduler/status", + response_model=SchedulerStatusResponse, + tags=["Scheduler"], + summary="Get scheduler status", + description="Get the current scheduler state and running job.", + ) + def get_scheduler_status( + state: AppState = Depends(get_state), + ) -> SchedulerStatusResponse: + scheduler = state.scheduler + assert scheduler is not None + current_job = scheduler.current_job + return SchedulerStatusResponse( + state=scheduler.state.value, + current_job=job_to_response(current_job) if current_job else None, + ) + + @app.post( + "/scheduler/start", + response_model=MessageResponse, + tags=["Scheduler"], + summary="Start scheduler", + description="Start processing jobs from the queue.", + ) + def start_scheduler( + state: AppState = Depends(get_state), + ) -> MessageResponse: + scheduler = state.scheduler + assert scheduler is not None + scheduler.start() + return MessageResponse(message="Scheduler started") + + @app.post( + "/scheduler/pause", + response_model=MessageResponse, + tags=["Scheduler"], + summary="Pause scheduler", + description="Pause after the current job completes.", + ) + def pause_scheduler( + state: AppState = Depends(get_state), + ) -> MessageResponse: + scheduler = state.scheduler + assert scheduler is not None + scheduler.pause() + return MessageResponse(message="Scheduler pausing after current job") + + @app.post( + "/scheduler/stop", + response_model=MessageResponse, + tags=["Scheduler"], + summary="Stop scheduler", + description="Stop the scheduler immediately.", + ) + def stop_scheduler( + state: AppState = Depends(get_state), + ) -> MessageResponse: + scheduler = state.scheduler + assert scheduler is not None + scheduler.stop() + return MessageResponse(message="Scheduler stopped") + + @app.post( + "/scheduler/tick", + response_model=SchedulerStatusResponse, + tags=["Scheduler"], + summary="Tick scheduler", + description="Manually trigger a scheduler tick for step-by-step control.", + ) + def tick_scheduler( + state: AppState = Depends(get_state), + ) -> SchedulerStatusResponse: + scheduler = state.scheduler + assert scheduler is not None + scheduler.tick() + current_job = scheduler.current_job + return SchedulerStatusResponse( + state=scheduler.state.value, + current_job=job_to_response(current_job) if current_job else None, + ) + + # ------------------------------------------------------------------------- + # Exception Handlers + # ------------------------------------------------------------------------- + + @app.exception_handler(Exception) + async def general_exception_handler( + request: Any, # noqa: ARG001 + exc: Exception, + ) -> JSONResponse: + """Handle unexpected exceptions.""" + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, ) - raise NotImplementedError("API integration will be implemented in Phase 2") + return app + + +__all__ = [ + "AddJobRequest", + "AddJobResponse", + "AppState", + "ErrorResponse", + "HealthResponse", + "JobResponse", + "MachineStatusResponse", + "MessageResponse", + "PositionResponse", + "QueueResponse", + "SchedulerStatusResponse", + "create_app", +] From 0c8629f0b4968fa029673a90077e14f0dcf1c799 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:21:24 -0800 Subject: [PATCH 03/11] feat(mqtt): add MQTT pub/sub client integration --- docs/api/integrations/mqtt.md | 263 ++++++++++- src/cnckit/integrations/mqtt/__init__.py | 504 ++++++++++++++++++++- tests/integrations/test_mqtt.py | 545 +++++++++++++++++++++++ 3 files changed, 1288 insertions(+), 24 deletions(-) create mode 100644 tests/integrations/test_mqtt.py diff --git a/docs/api/integrations/mqtt.md b/docs/api/integrations/mqtt.md index 48efc58..8cf8817 100644 --- a/docs/api/integrations/mqtt.md +++ b/docs/api/integrations/mqtt.md @@ -11,10 +11,28 @@ pip install cnckit[mqtt] ## Quick Start ```python +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter from cnckit.integrations.mqtt import MQTTClient -client = MQTTClient(broker="localhost", port=1883) +# Set up core components +machine = Machine(simulate=True) +queue = JobQueue() +events = EventEmitter() +scheduler = Scheduler(machine, queue, events) + +# Create and connect MQTT client +client = MQTTClient("localhost", port=1883) client.connect() + +# Bind to events and scheduler +client.bind_events(events) # Auto-publish job events +client.bind_scheduler(scheduler) # Handle commands + +# Run your application +scheduler.run_forever() + +# When done +client.disconnect() ``` ## API Reference @@ -24,19 +42,238 @@ client.connect() show_root_heading: true heading_level: 3 +::: cnckit.integrations.mqtt.MQTTTopics + options: + show_root_heading: true + heading_level: 3 + ## Topics -!!! note "Coming in Phase 2" - The MQTT integration will be implemented in Phase 2. +### Published Topics (Outgoing) + +| Topic | Description | Retained | +|-------|-------------|----------| +| `cnckit/machine/state` | Machine state updates | Yes | +| `cnckit/job/started` | Job started events | No | +| `cnckit/job/completed` | Job completed events | No | +| `cnckit/job/failed` | Job failed events | No | +| `cnckit/queue/empty` | Queue empty notification | No | + +### Subscribed Topics (Incoming Commands) + +| Topic | Description | +|-------|-------------| +| `cnckit/commands/start` | Start the scheduler | +| `cnckit/commands/pause` | Pause after current job | +| `cnckit/commands/stop` | Stop the scheduler | + +## Message Format + +All messages use a consistent JSON format: + +```json +{ + "type": "message_type", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + // Type-specific data + } +} +``` + +### Machine State Message + +```json +{ + "type": "machine_state", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "state": "running", + "position": {"x": 10.5, "y": 20.0, "z": -1.0}, + "progress": 0.45, + "current_program": "/path/to/part.ngc" + } +} +``` + +### Job Event Messages + +```json +{ + "type": "job_started", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "job": { + "id": "/path/to/part.ngc", + "name": "part", + "path": "/path/to/part.ngc", + "priority": 0, + "status": "running", + "estimated_time": null, + "created_at": "2024-01-15T10:25:00.123456", + "started_at": "2024-01-15T10:30:00.123456", + "completed_at": null, + "error": null + } + } +} +``` + +### Job Failed Message + +```json +{ + "type": "job_failed", + "timestamp": "2024-01-15T10:35:00.123456", + "data": { + "job": { ... }, + "error": "Tool broke during operation" + } +} +``` + +## Custom Topic Prefix + +Customize topic names with a different prefix: + +```python +from cnckit.integrations.mqtt import MQTTClient, MQTTTopics + +# Use custom prefix +topics = MQTTTopics(prefix="factory/cnc1") +client = MQTTClient("localhost", topics=topics) + +# Topics will be: +# - factory/cnc1/machine/state +# - factory/cnc1/job/started +# - factory/cnc1/commands/start +# etc. +``` + +## Authentication + +Connect with username/password authentication: + +```python +client = MQTTClient( + broker="mqtt.example.com", + port=8883, # TLS port + username="cnckit", + password="secret", +) +client.connect() +``` + +## Custom Command Handlers -Planned topics: +Register handlers for custom command topics: -| Topic | Direction | Description | -|-------|-----------|-------------| -| `cnckit/machine/state` | Publish | Machine state updates | -| `cnckit/job/started` | Publish | Job started events | -| `cnckit/job/completed` | Publish | Job completed events | -| `cnckit/job/failed` | Publish | Job failed events | -| `cnckit/commands/start` | Subscribe | Start command | -| `cnckit/commands/pause` | Subscribe | Pause command | -| `cnckit/commands/stop` | Subscribe | Stop command | +```python +def handle_emergency_stop(payload): + print(f"Emergency stop: {payload}") + scheduler.stop() + machine.abort() + +client.on_command("cnckit/commands/emergency", handle_emergency_stop) +``` + +## Manual Publishing + +Publish messages directly: + +```python +# Publish machine state +client.publish_machine_state( + state="running", + position={"x": 10.0, "y": 20.0, "z": 5.0}, + progress=0.5, + current_program="/path/to/part.ngc", +) + +# Publish raw message +client.publish( + topic="cnckit/custom/topic", + payload={"custom": "data"}, + qos=1, + retain=False, +) +``` + +## Integration Example + +Complete example with machine state polling: + +```python +import time +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter +from cnckit.integrations.mqtt import MQTTClient + +# Set up components +machine = Machine(simulate=True) +queue = JobQueue() +events = EventEmitter() +scheduler = Scheduler(machine, queue, events) + +# Set up MQTT +client = MQTTClient("localhost") +client.connect() +client.bind_events(events) +client.bind_scheduler(scheduler) + +# Add some jobs +queue.add("/jobs/part1.ngc") +queue.add("/jobs/part2.ngc") + +# Main loop with state publishing +scheduler.start() +try: + while scheduler.state.value != "stopped": + scheduler.tick() + + # Publish current machine state + pos = machine.position + client.publish_machine_state( + state=machine.state.value, + position={"x": pos.x, "y": pos.y, "z": pos.z}, + progress=machine.progress, + current_program=machine.current_program, + ) + + time.sleep(1.0) +finally: + client.disconnect() +``` + +## Subscribing from Another Client + +Example using mosquitto_sub to receive events: + +```bash +# Subscribe to all cnckit topics +mosquitto_sub -h localhost -t "cnckit/#" -v + +# Send start command +mosquitto_pub -h localhost -t "cnckit/commands/start" -m "{}" + +# Send stop command +mosquitto_pub -h localhost -t "cnckit/commands/stop" -m "{}" +``` + +## Testing with Mock Broker + +For testing without a real broker, use the mock pattern: + +```python +from unittest.mock import patch, MagicMock + +with patch("paho.mqtt.client.Client") as mock_mqtt: + mock_client = MagicMock() + mock_mqtt.return_value = mock_client + + client = MQTTClient("localhost") + client._connected = True # Skip actual connection + + # Test publishing + client.publish_queue_empty() + mock_client.publish.assert_called() +``` diff --git a/src/cnckit/integrations/mqtt/__init__.py b/src/cnckit/integrations/mqtt/__init__.py index 7a327ce..567a236 100644 --- a/src/cnckit/integrations/mqtt/__init__.py +++ b/src/cnckit/integrations/mqtt/__init__.py @@ -5,6 +5,133 @@ Requires: pip install cnckit[mqtt] """ +from __future__ import annotations + +import contextlib +import json +import threading +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any + +# Validate paho-mqtt is available on import +try: + import paho.mqtt.client as mqtt +except ImportError: + raise ImportError( + "paho-mqtt is required for MQTT integration. " + "Install with: pip install cnckit[mqtt]" + ) from None + +if TYPE_CHECKING: + from collections.abc import Callable + + from cnckit.core import Event, EventEmitter, Job, Scheduler + + +# ============================================================================= +# Topic Configuration +# ============================================================================= + + +@dataclass +class MQTTTopics: + """ + MQTT topic configuration. + + Customize topic names by creating an instance with different values. + All topics use a common prefix for easy filtering. + + Attributes: + prefix: Base prefix for all topics (default: "cnckit") + machine_state: Topic for machine state updates + job_started: Topic for job started events + job_completed: Topic for job completed events + job_failed: Topic for job failed events + queue_empty: Topic for queue empty events + command_start: Topic for start commands + command_pause: Topic for pause commands + command_stop: Topic for stop commands + """ + + prefix: str = "cnckit" + + # Publish topics (outgoing) + machine_state: str = field(init=False) + job_started: str = field(init=False) + job_completed: str = field(init=False) + job_failed: str = field(init=False) + queue_empty: str = field(init=False) + + # Subscribe topics (incoming commands) + command_start: str = field(init=False) + command_pause: str = field(init=False) + command_stop: str = field(init=False) + + def __post_init__(self) -> None: + """Build topic names from prefix.""" + self.machine_state = f"{self.prefix}/machine/state" + self.job_started = f"{self.prefix}/job/started" + self.job_completed = f"{self.prefix}/job/completed" + self.job_failed = f"{self.prefix}/job/failed" + self.queue_empty = f"{self.prefix}/queue/empty" + self.command_start = f"{self.prefix}/commands/start" + self.command_pause = f"{self.prefix}/commands/pause" + self.command_stop = f"{self.prefix}/commands/stop" + + @property + def all_command_topics(self) -> list[str]: + """Get all command topics for subscription.""" + return [self.command_start, self.command_pause, self.command_stop] + + +# ============================================================================= +# Message Formatting +# ============================================================================= + + +def job_to_dict(job: Job) -> dict[str, Any]: + """Convert a Job instance to a dictionary for JSON serialization.""" + return { + "id": str(job.path), + "name": job.name or job.path.stem, + "path": str(job.path), + "priority": job.priority, + "status": job.status.value, + "estimated_time": job.estimated_time, + "created_at": job.created_at.isoformat(), + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error": job.error, + } + + +def create_message( + msg_type: str, + data: dict[str, Any] | None = None, +) -> str: + """ + Create a JSON message with standard format. + + Args: + msg_type: Message type identifier + data: Optional data payload + + Returns: + JSON string with type, timestamp, and data + """ + message = { + "type": msg_type, + "timestamp": datetime.now().isoformat(), + "data": data or {}, + } + return json.dumps(message) + + +# ============================================================================= +# MQTT Client +# ============================================================================= + class MQTTClient: """ @@ -13,24 +140,379 @@ class MQTTClient: Publishes machine state and job events to MQTT topics. Subscribes to command topics for remote control. - Raises: - ImportError: If paho-mqtt is not installed + The client integrates with cnckit's EventEmitter to automatically + publish events, and with the Scheduler to handle incoming commands. + + Example: + >>> from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter + >>> from cnckit.integrations.mqtt import MQTTClient + >>> + >>> # Set up core components + >>> machine = Machine(simulate=True) + >>> queue = JobQueue() + >>> events = EventEmitter() + >>> scheduler = Scheduler(machine, queue, events) + >>> + >>> # Create MQTT client + >>> client = MQTTClient("localhost", port=1883) + >>> client.connect() + >>> client.bind_events(events) + >>> client.bind_scheduler(scheduler) + >>> + >>> # Client will now publish events and respond to commands + >>> # Run your scheduler loop... + >>> scheduler.run_forever() + >>> + >>> # When done + >>> client.disconnect() """ - def __init__(self, broker: str, port: int = 1883): + def __init__( + self, + broker: str, + port: int = 1883, + client_id: str | None = None, + topics: MQTTTopics | None = None, + username: str | None = None, + password: str | None = None, + keepalive: int = 60, + ) -> None: """ Initialize MQTT client. Args: - broker: MQTT broker hostname - port: MQTT broker port + broker: MQTT broker hostname or IP address + port: MQTT broker port (default: 1883, use 8883 for TLS) + client_id: Optional client identifier (auto-generated if not provided) + topics: Optional custom topic configuration + username: Optional username for authentication + password: Optional password for authentication + keepalive: Keepalive interval in seconds (default: 60) """ + self._broker = broker + self._port = port + self._keepalive = keepalive + self._topics = topics if topics is not None else MQTTTopics() + + # Create paho MQTT client + # Use CallbackAPIVersion.VERSION2 for paho-mqtt 2.0+ try: - import paho.mqtt.client # noqa: F401 - except ImportError: - raise ImportError( - "paho-mqtt is required for MQTT integration. " - "Install with: pip install cnckit[mqtt]" + self._client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, # type: ignore[attr-defined] + client_id=client_id or "", ) + except (AttributeError, TypeError): + # Fallback for older paho-mqtt versions + self._client = mqtt.Client(client_id=client_id or "") + + # Set up authentication if provided + if username is not None: + self._client.username_pw_set(username, password) + + # Set up callbacks + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + # State + self._connected = False + self._scheduler: Scheduler | None = None + self._event_handlers: list[tuple[Event, Callable[..., Any]]] = [] + self._events: EventEmitter | None = None + self._lock = threading.Lock() + + # Custom message handlers + self._command_handlers: dict[str, Callable[[dict[str, Any]], None]] = {} + + @property + def broker(self) -> str: + """Get the broker hostname.""" + return self._broker + + @property + def port(self) -> int: + """Get the broker port.""" + return self._port + + @property + def topics(self) -> MQTTTopics: + """Get the topic configuration.""" + return self._topics + + @property + def is_connected(self) -> bool: + """Check if client is connected to broker.""" + return self._connected + + # ------------------------------------------------------------------------- + # Connection Management + # ------------------------------------------------------------------------- + + def connect(self, timeout: float = 10.0) -> None: + """ + Connect to the MQTT broker. + + Args: + timeout: Connection timeout in seconds + + Raises: + ConnectionError: If connection fails + """ + try: + self._client.connect(self._broker, self._port, self._keepalive) + self._client.loop_start() + + # Wait for connection with timeout + import time + + start = time.monotonic() + while not self._connected and (time.monotonic() - start) < timeout: + time.sleep(0.1) + + if not self._connected: + self._client.loop_stop() + raise ConnectionError( + f"Failed to connect to MQTT broker at {self._broker}:{self._port}" + ) + except Exception as e: + raise ConnectionError(f"MQTT connection failed: {e}") from e + + def disconnect(self) -> None: + """ + Disconnect from the MQTT broker. + + Also unbinds any event handlers that were registered. + """ + self._unbind_events() + self._client.loop_stop() + self._client.disconnect() + self._connected = False + + def _on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: Any, + rc: int | Any, # ReasonCode in paho-mqtt 2.0+ + properties: Any = None, + ) -> None: + """Handle connection established.""" + # rc can be int (old API) or ReasonCode (new API) + success = rc == 0 if isinstance(rc, int) else rc.is_failure is False + + if success: + self._connected = True + # Subscribe to command topics + for topic in self._topics.all_command_topics: + client.subscribe(topic) + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + rc: int | Any | None = None, # ReasonCode in paho-mqtt 2.0+ + properties: Any = None, + ) -> None: + """Handle disconnection.""" + self._connected = False + + # ------------------------------------------------------------------------- + # Publishing + # ------------------------------------------------------------------------- + + def publish( + self, + topic: str, + payload: str | dict[str, Any], + qos: int = 1, + retain: bool = False, + ) -> None: + """ + Publish a message to a topic. + + Args: + topic: MQTT topic to publish to + payload: Message payload (string or dict to be JSON-encoded) + qos: Quality of Service level (0, 1, or 2) + retain: Whether broker should retain the message + """ + if isinstance(payload, dict): + payload = json.dumps(payload) + + self._client.publish(topic, payload, qos=qos, retain=retain) + + def publish_machine_state( + self, + state: str, + position: dict[str, float] | None = None, + progress: float = 0.0, + current_program: str | None = None, + ) -> None: + """ + Publish machine state update. + + Args: + state: Machine state (idle, running, etc.) + position: Optional position dict with x, y, z keys + progress: Program progress (0.0 to 1.0) + current_program: Path to current program + """ + data = { + "state": state, + "position": position or {"x": 0.0, "y": 0.0, "z": 0.0}, + "progress": progress, + "current_program": current_program, + } + message = create_message("machine_state", data) + self.publish(self._topics.machine_state, message, retain=True) + + def publish_job_started(self, job: Job) -> None: + """Publish job started event.""" + message = create_message("job_started", {"job": job_to_dict(job)}) + self.publish(self._topics.job_started, message) + + def publish_job_completed(self, job: Job) -> None: + """Publish job completed event.""" + message = create_message("job_completed", {"job": job_to_dict(job)}) + self.publish(self._topics.job_completed, message) + + def publish_job_failed(self, job: Job, error: str | None = None) -> None: + """Publish job failed event.""" + data = {"job": job_to_dict(job), "error": error or job.error} + message = create_message("job_failed", data) + self.publish(self._topics.job_failed, message) + + def publish_queue_empty(self) -> None: + """Publish queue empty event.""" + message = create_message("queue_empty") + self.publish(self._topics.queue_empty, message) + + # ------------------------------------------------------------------------- + # Command Handling + # ------------------------------------------------------------------------- + + def _on_message( + self, + client: mqtt.Client, + userdata: Any, + msg: mqtt.MQTTMessage, + ) -> None: + """Handle incoming MQTT messages.""" + topic = msg.topic + try: + payload = json.loads(msg.payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + + with self._lock: + # Check for custom handlers first + if topic in self._command_handlers: + with contextlib.suppress(Exception): + self._command_handlers[topic](payload) + return + + # Handle built-in command topics + if self._scheduler is not None: + if topic == self._topics.command_start: + self._scheduler.start() + elif topic == self._topics.command_pause: + self._scheduler.pause() + elif topic == self._topics.command_stop: + self._scheduler.stop() + + def on_command( + self, + topic: str, + handler: Callable[[dict[str, Any]], None], + ) -> None: + """ + Register a custom command handler for a topic. + + Args: + topic: MQTT topic to handle + handler: Callback function receiving the message payload dict + + Example: + >>> def handle_custom(payload): + ... print(f"Received: {payload}") + >>> client.on_command("cnckit/commands/custom", handle_custom) + """ + with self._lock: + self._command_handlers[topic] = handler + # Subscribe to the topic if connected + if self._connected: + self._client.subscribe(topic) + + # ------------------------------------------------------------------------- + # Event Binding + # ------------------------------------------------------------------------- + + def bind_scheduler(self, scheduler: Scheduler) -> None: + """ + Bind a scheduler for command handling. + + When bound, incoming commands on command topics will control + the scheduler (start, pause, stop). + + Args: + scheduler: Scheduler instance to control + """ + self._scheduler = scheduler + + def bind_events(self, events: EventEmitter) -> None: + """ + Bind to an EventEmitter to automatically publish events. + + When bound, the client will publish MQTT messages for: + - JOB_STARTED -> job/started topic + - JOB_COMPLETED -> job/completed topic + - JOB_FAILED -> job/failed topic + - QUEUE_EMPTY -> queue/empty topic + + Args: + events: EventEmitter instance to listen to + """ + from cnckit.core import Event + + self._events = events + + # Define handlers + def on_job_started(job: Job) -> None: + self.publish_job_started(job) + + def on_job_completed(job: Job) -> None: + self.publish_job_completed(job) + + def on_job_failed(job: Job, error: str | None = None) -> None: + self.publish_job_failed(job, error) + + def on_queue_empty() -> None: + self.publish_queue_empty() + + # Register handlers + handlers: list[tuple[Event, Callable[..., Any]]] = [ + (Event.JOB_STARTED, on_job_started), + (Event.JOB_COMPLETED, on_job_completed), + (Event.JOB_FAILED, on_job_failed), + (Event.QUEUE_EMPTY, on_queue_empty), + ] + + for event, handler in handlers: + events.on(event, handler) + self._event_handlers.append((event, handler)) + + def _unbind_events(self) -> None: + """Remove event handlers from the EventEmitter.""" + if self._events is not None: + for event, handler in self._event_handlers: + self._events.off(event, handler) + self._event_handlers.clear() + self._events = None + - raise NotImplementedError("MQTT integration will be implemented in Phase 2") +__all__ = [ + "MQTTClient", + "MQTTTopics", + "create_message", + "job_to_dict", +] diff --git a/tests/integrations/test_mqtt.py b/tests/integrations/test_mqtt.py new file mode 100644 index 0000000..bca5a07 --- /dev/null +++ b/tests/integrations/test_mqtt.py @@ -0,0 +1,545 @@ +"""Tests for MQTT integration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Check if paho-mqtt is available for testing +try: + import paho.mqtt.client # noqa: F401 + + PAHO_AVAILABLE = True +except ImportError: + PAHO_AVAILABLE = False + + +class TestMQTTWithoutPaho: + """Tests that work without paho-mqtt installed.""" + + def test_import_raises_without_paho(self): + """Import should raise ImportError if paho-mqtt not installed.""" + if PAHO_AVAILABLE: + pytest.skip("paho-mqtt is installed, cannot test ImportError") + + with pytest.raises(ImportError, match="pip install cnckit\\[mqtt\\]"): + from cnckit.integrations.mqtt import MQTTClient # noqa: F401 + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTTopics: + """Tests for MQTTTopics configuration.""" + + def test_default_prefix(self): + """Default prefix is 'cnckit'.""" + from cnckit.integrations.mqtt import MQTTTopics + + topics = MQTTTopics() + assert topics.prefix == "cnckit" + + def test_custom_prefix(self): + """Custom prefix updates all topic names.""" + from cnckit.integrations.mqtt import MQTTTopics + + topics = MQTTTopics(prefix="myapp") + assert topics.machine_state == "myapp/machine/state" + assert topics.job_started == "myapp/job/started" + assert topics.command_start == "myapp/commands/start" + + def test_all_command_topics(self): + """all_command_topics returns all command topics.""" + from cnckit.integrations.mqtt import MQTTTopics + + topics = MQTTTopics() + commands = topics.all_command_topics + assert len(commands) == 3 + assert "cnckit/commands/start" in commands + assert "cnckit/commands/pause" in commands + assert "cnckit/commands/stop" in commands + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMessageFormatting: + """Tests for message creation functions.""" + + def test_create_message_basic(self): + """create_message creates valid JSON with type and timestamp.""" + from cnckit.integrations.mqtt import create_message + + msg = create_message("test_type") + parsed = json.loads(msg) + + assert parsed["type"] == "test_type" + assert "timestamp" in parsed + assert "T" in parsed["timestamp"] # ISO format + assert parsed["data"] == {} + + def test_create_message_with_data(self): + """create_message includes data payload.""" + from cnckit.integrations.mqtt import create_message + + data = {"key": "value", "number": 42} + msg = create_message("test_type", data) + parsed = json.loads(msg) + + assert parsed["data"] == data + + def test_job_to_dict(self): + """job_to_dict converts Job to dictionary.""" + from cnckit.core import Job + from cnckit.integrations.mqtt import job_to_dict + + job = Job(path=Path("/test/part.ngc"), priority=5, name="Test Part") + result = job_to_dict(job) + + assert result["id"] == "/test/part.ngc" + assert result["name"] == "Test Part" + assert result["path"] == "/test/part.ngc" + assert result["priority"] == 5 + assert result["status"] == "pending" + assert "created_at" in result + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTClientInit: + """Tests for MQTTClient initialization.""" + + def test_init_with_defaults(self): + """MQTTClient initializes with default values.""" + from cnckit.integrations.mqtt import MQTTClient + + with patch("paho.mqtt.client.Client"): + client = MQTTClient("localhost") + + assert client.broker == "localhost" + assert client.port == 1883 + assert client.is_connected is False + + def test_init_with_custom_port(self): + """MQTTClient accepts custom port.""" + from cnckit.integrations.mqtt import MQTTClient + + with patch("paho.mqtt.client.Client"): + client = MQTTClient("localhost", port=8883) + + assert client.port == 8883 + + def test_init_with_custom_topics(self): + """MQTTClient accepts custom topics configuration.""" + from cnckit.integrations.mqtt import MQTTClient, MQTTTopics + + topics = MQTTTopics(prefix="custom") + with patch("paho.mqtt.client.Client"): + client = MQTTClient("localhost", topics=topics) + + assert client.topics.prefix == "custom" + + def test_init_with_auth(self): + """MQTTClient sets up authentication when provided.""" + from cnckit.integrations.mqtt import MQTTClient + + mock_client = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock_client): + MQTTClient("localhost", username="user", password="pass") + + mock_client.username_pw_set.assert_called_once_with("user", "pass") + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTClientPublish: + """Tests for MQTT publishing methods.""" + + @pytest.fixture + def mock_client(self): + """Create a mocked MQTT client.""" + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + yield client, mock + + def test_publish_string(self, mock_client): + """publish sends string payload.""" + client, mock = mock_client + + client.publish("test/topic", "hello") + + mock.publish.assert_called_once_with("test/topic", "hello", qos=1, retain=False) + + def test_publish_dict(self, mock_client): + """publish JSON-encodes dict payload.""" + client, mock = mock_client + + client.publish("test/topic", {"key": "value"}) + + call_args = mock.publish.call_args + payload = call_args[0][1] + assert json.loads(payload) == {"key": "value"} + + def test_publish_with_options(self, mock_client): + """publish respects qos and retain options.""" + client, mock = mock_client + + client.publish("test/topic", "data", qos=2, retain=True) + + mock.publish.assert_called_once_with("test/topic", "data", qos=2, retain=True) + + def test_publish_machine_state(self, mock_client): + """publish_machine_state sends formatted message.""" + client, mock = mock_client + + client.publish_machine_state( + state="running", + position={"x": 10.0, "y": 20.0, "z": 5.0}, + progress=0.5, + current_program="/test.ngc", + ) + + call_args = mock.publish.call_args + topic = call_args[0][0] + payload = json.loads(call_args[0][1]) + + assert topic == "cnckit/machine/state" + assert payload["type"] == "machine_state" + assert payload["data"]["state"] == "running" + assert payload["data"]["progress"] == 0.5 + + def test_publish_job_started(self, mock_client): + """publish_job_started sends job data.""" + from cnckit.core import Job + + client, mock = mock_client + job = Job(path=Path("/test.ngc")) + + client.publish_job_started(job) + + call_args = mock.publish.call_args + topic = call_args[0][0] + payload = json.loads(call_args[0][1]) + + assert topic == "cnckit/job/started" + assert payload["type"] == "job_started" + assert payload["data"]["job"]["path"] == "/test.ngc" + + def test_publish_job_completed(self, mock_client): + """publish_job_completed sends job data.""" + from cnckit.core import Job + + client, mock = mock_client + job = Job(path=Path("/test.ngc")) + job.mark_completed() + + client.publish_job_completed(job) + + call_args = mock.publish.call_args + topic = call_args[0][0] + payload = json.loads(call_args[0][1]) + + assert topic == "cnckit/job/completed" + assert payload["type"] == "job_completed" + + def test_publish_job_failed(self, mock_client): + """publish_job_failed sends job data with error.""" + from cnckit.core import Job + + client, mock = mock_client + job = Job(path=Path("/test.ngc")) + job.mark_failed("Tool broke") + + client.publish_job_failed(job, error="Tool broke") + + call_args = mock.publish.call_args + payload = json.loads(call_args[0][1]) + + assert payload["data"]["error"] == "Tool broke" + + def test_publish_queue_empty(self, mock_client): + """publish_queue_empty sends empty message.""" + client, mock = mock_client + + client.publish_queue_empty() + + call_args = mock.publish.call_args + topic = call_args[0][0] + payload = json.loads(call_args[0][1]) + + assert topic == "cnckit/queue/empty" + assert payload["type"] == "queue_empty" + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTClientCommands: + """Tests for MQTT command handling.""" + + @pytest.fixture + def mock_client_with_scheduler(self): + """Create mocked MQTT client with scheduler.""" + from cnckit.core import EventEmitter, JobQueue, Machine, Scheduler + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + client.bind_scheduler(scheduler) + + yield client, mock, scheduler + + def test_bind_scheduler(self, mock_client_with_scheduler): + """bind_scheduler stores scheduler reference.""" + client, _, scheduler = mock_client_with_scheduler + assert client._scheduler is scheduler + + def test_command_start(self, mock_client_with_scheduler): + """Start command starts scheduler.""" + from cnckit.core import SchedulerState + + client, mock, scheduler = mock_client_with_scheduler + + # Simulate receiving start command + msg = MagicMock() + msg.topic = "cnckit/commands/start" + msg.payload = b"{}" + + client._on_message(mock, None, msg) + + # Scheduler should be running (but stops immediately due to empty queue) + # The start() was called though + assert scheduler.state in (SchedulerState.RUNNING, SchedulerState.STOPPED) + + def test_command_pause(self, mock_client_with_scheduler): + """Pause command calls scheduler.pause().""" + from cnckit.core import SchedulerState + + client, mock, scheduler = mock_client_with_scheduler + + # Simulate receiving pause command when scheduler is stopped + # pause() on a stopped scheduler is a no-op, but we verify the command works + msg = MagicMock() + msg.topic = "cnckit/commands/pause" + msg.payload = b"{}" + + client._on_message(mock, None, msg) + + # Scheduler was stopped, so pause is a no-op - stays stopped + assert scheduler.state == SchedulerState.STOPPED + + def test_command_pause_from_running(self, mock_client_with_scheduler): + """Pause command pauses running scheduler.""" + from cnckit.core import SchedulerState + + client, mock, scheduler = mock_client_with_scheduler + + # Manually set scheduler to running state (bypassing empty queue issue) + scheduler._state = SchedulerState.RUNNING + + msg = MagicMock() + msg.topic = "cnckit/commands/pause" + msg.payload = b"{}" + + client._on_message(mock, None, msg) + + # Scheduler should be paused + assert scheduler.state == SchedulerState.PAUSED + + def test_command_stop(self, mock_client_with_scheduler): + """Stop command stops scheduler.""" + from cnckit.core import SchedulerState + + client, mock, scheduler = mock_client_with_scheduler + + # Simulate receiving stop command + msg = MagicMock() + msg.topic = "cnckit/commands/stop" + msg.payload = b"{}" + + client._on_message(mock, None, msg) + + assert scheduler.state == SchedulerState.STOPPED + + def test_custom_command_handler(self): + """Custom command handlers are called.""" + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + + received: list[dict[str, Any]] = [] + + def handler(payload: dict[str, Any]) -> None: + received.append(payload) + + client.on_command("custom/topic", handler) + + # Simulate receiving message + msg = MagicMock() + msg.topic = "custom/topic" + msg.payload = b'{"action": "test"}' + + client._on_message(mock, None, msg) + + assert len(received) == 1 + assert received[0] == {"action": "test"} + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTClientEventBinding: + """Tests for event binding functionality.""" + + @pytest.fixture + def mock_client_with_events(self): + """Create mocked MQTT client with event emitter.""" + from cnckit.core import EventEmitter + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + + events = EventEmitter() + client.bind_events(events) + + yield client, mock, events + + def test_bind_events_registers_handlers(self, mock_client_with_events): + """bind_events registers event handlers.""" + _client, _, events = mock_client_with_events + + from cnckit.core import Event + + # Check handlers are registered + assert len(events.listeners(Event.JOB_STARTED)) > 0 + assert len(events.listeners(Event.JOB_COMPLETED)) > 0 + assert len(events.listeners(Event.JOB_FAILED)) > 0 + assert len(events.listeners(Event.QUEUE_EMPTY)) > 0 + + def test_event_publishes_job_started(self, mock_client_with_events): + """JOB_STARTED event publishes to MQTT.""" + from cnckit.core import Event, Job + + _client, mock, events = mock_client_with_events + + job = Job(path=Path("/test.ngc")) + events.emit(Event.JOB_STARTED, job) + + # Check publish was called + assert mock.publish.called + call_args = mock.publish.call_args + topic = call_args[0][0] + assert topic == "cnckit/job/started" + + def test_event_publishes_job_completed(self, mock_client_with_events): + """JOB_COMPLETED event publishes to MQTT.""" + from cnckit.core import Event, Job + + _client, mock, events = mock_client_with_events + + job = Job(path=Path("/test.ngc")) + events.emit(Event.JOB_COMPLETED, job) + + call_args = mock.publish.call_args + topic = call_args[0][0] + assert topic == "cnckit/job/completed" + + def test_event_publishes_queue_empty(self, mock_client_with_events): + """QUEUE_EMPTY event publishes to MQTT.""" + from cnckit.core import Event + + _client, mock, events = mock_client_with_events + + events.emit(Event.QUEUE_EMPTY) + + call_args = mock.publish.call_args + topic = call_args[0][0] + assert topic == "cnckit/queue/empty" + + def test_unbind_events_removes_handlers(self, mock_client_with_events): + """_unbind_events removes all event handlers.""" + from cnckit.core import Event + + client, _, events = mock_client_with_events + + client._unbind_events() + + # Handlers should be removed + assert len(events.listeners(Event.JOB_STARTED)) == 0 + assert len(events.listeners(Event.JOB_COMPLETED)) == 0 + + +@pytest.mark.skipif(not PAHO_AVAILABLE, reason="paho-mqtt not installed") +class TestMQTTClientConnection: + """Tests for connection handling.""" + + def test_on_connect_success(self): + """_on_connect sets connected flag on success.""" + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + + # Simulate successful connection (rc=0 for old API) + client._on_connect(mock, None, None, 0) + + assert client.is_connected is True + + def test_on_connect_subscribes_to_commands(self): + """_on_connect subscribes to command topics.""" + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + + client._on_connect(mock, None, None, 0) + + # Should subscribe to all command topics + assert mock.subscribe.call_count == 3 + + def test_on_disconnect_clears_flag(self): + """_on_disconnect clears connected flag.""" + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + + client._on_disconnect(mock, None, 0) + + assert client.is_connected is False + + def test_disconnect_unbinds_events(self): + """disconnect unbinds event handlers.""" + from cnckit.core import Event, EventEmitter + from cnckit.integrations.mqtt import MQTTClient + + mock = MagicMock() + with patch("paho.mqtt.client.Client", return_value=mock): + client = MQTTClient("localhost") + client._connected = True + + events = EventEmitter() + client.bind_events(events) + + # Verify handlers exist + assert len(events.listeners(Event.JOB_STARTED)) > 0 + + client.disconnect() + + # Handlers should be removed + assert len(events.listeners(Event.JOB_STARTED)) == 0 From f75bd7ca6d93e7bcba530b97f9ec1699aaf537c5 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:21:47 -0800 Subject: [PATCH 04/11] feat(websocket): add WebSocket streaming server --- docs/api/integrations/websocket.md | 342 +++++++++++- src/cnckit/integrations/websocket/__init__.py | 430 ++++++++++++++- tests/integrations/test_websocket.py | 512 ++++++++++++++++++ 3 files changed, 1263 insertions(+), 21 deletions(-) create mode 100644 tests/integrations/test_websocket.py diff --git a/docs/api/integrations/websocket.md b/docs/api/integrations/websocket.md index 8dfa6f7..9092b61 100644 --- a/docs/api/integrations/websocket.md +++ b/docs/api/integrations/websocket.md @@ -1,6 +1,6 @@ # WebSocket Integration -The WebSocket integration provides real-time streaming of machine state and job progress. +The WebSocket integration provides real-time streaming of machine state and job progress to connected clients. ## Installation @@ -11,10 +11,27 @@ pip install cnckit[websocket] ## Quick Start ```python +import asyncio +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter from cnckit.integrations.websocket import WebSocketServer -server = WebSocketServer(host="localhost", port=8765) -await server.start() +async def main(): + # Set up core components + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + + # Create and start WebSocket server + server = WebSocketServer(host="localhost", port=8765) + server.bind_events(events) + + async with server: + print(f"WebSocket server running on ws://localhost:8765") + # Run your application... + await asyncio.sleep(60) + +asyncio.run(main()) ``` ## API Reference @@ -26,19 +43,328 @@ await server.start() ## Message Format -!!! note "Coming in Phase 2" - The WebSocket integration will be implemented in Phase 2. +All messages use a consistent JSON format: + +```json +{ + "type": "message_type", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + // Type-specific data + } +} +``` + +## Message Types + +### Connection Messages + +**connected** - Sent when client connects: +```json +{ + "type": "connected", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "message": "Connected to CNCKit" + } +} +``` + +**pong** - Response to client ping: +```json +{ + "type": "pong", + "timestamp": "2024-01-15T10:30:00.123456", + "data": {} +} +``` -Messages will be JSON-formatted: +### Machine State +**machine_state** - Current machine status: ```json { "type": "machine_state", - "timestamp": "2024-01-15T10:30:00Z", + "timestamp": "2024-01-15T10:30:00.123456", "data": { "state": "running", "position": {"x": 10.5, "y": 20.0, "z": -1.0}, - "current_job": "part1.ngc" + "progress": 0.45, + "current_program": "/path/to/part.ngc", + "tool": 1 } } ``` + +### Job Events + +**job_started**: +```json +{ + "type": "job_started", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "job": { + "id": "/path/to/part.ngc", + "name": "part", + "path": "/path/to/part.ngc", + "priority": 0, + "status": "running", + "estimated_time": null, + "created_at": "2024-01-15T10:25:00.123456", + "started_at": "2024-01-15T10:30:00.123456", + "completed_at": null, + "error": null + } + } +} +``` + +**job_completed**: +```json +{ + "type": "job_completed", + "timestamp": "2024-01-15T10:35:00.123456", + "data": { + "job": { ... } + } +} +``` + +**job_failed**: +```json +{ + "type": "job_failed", + "timestamp": "2024-01-15T10:35:00.123456", + "data": { + "job": { ... }, + "error": "Tool broke during operation" + } +} +``` + +### Scheduler State + +**scheduler_state**: +```json +{ + "type": "scheduler_state", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "state": "running", + "current_job": { ... } + } +} +``` + +**queue_empty**: +```json +{ + "type": "queue_empty", + "timestamp": "2024-01-15T10:40:00.123456", + "data": {} +} +``` + +## Server Methods + +### Starting and Stopping + +```python +# Method 1: Context manager (recommended) +async with server: + # Server is running + pass +# Server is stopped + +# Method 2: Manual control +await server.start() +# ... do work ... +await server.stop() +``` + +### Broadcasting Messages + +```python +# Broadcast raw message +await server.broadcast('{"type": "custom", "data": {}}') + +# Broadcast machine state +await server.broadcast_machine_state( + state="running", + position={"x": 10.0, "y": 20.0, "z": 5.0}, + progress=0.5, + current_program="/path/to/part.ngc", + tool=1, +) + +# Broadcast job events +await server.broadcast_job_started(job) +await server.broadcast_job_completed(job) +await server.broadcast_job_failed(job, error="Tool broke") +await server.broadcast_queue_empty() + +# Broadcast scheduler state +await server.broadcast_scheduler_state( + state="running", + current_job=job, +) +``` + +## Event Binding + +Automatically broadcast events from EventEmitter: + +```python +from cnckit.core import EventEmitter +from cnckit.integrations.websocket import WebSocketServer + +events = EventEmitter() +server = WebSocketServer() +server.bind_events(events) + +# Now when events are emitted, they're automatically broadcast: +# Event.JOB_STARTED -> "job_started" message +# Event.JOB_COMPLETED -> "job_completed" message +# Event.JOB_FAILED -> "job_failed" message +# Event.QUEUE_EMPTY -> "queue_empty" message +``` + +## Complete Example + +```python +import asyncio +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter +from cnckit.integrations.websocket import WebSocketServer + +async def main(): + # Set up core components + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + + # Add some jobs + queue.add("/jobs/part1.ngc") + queue.add("/jobs/part2.ngc") + + # Create WebSocket server + server = WebSocketServer(host="0.0.0.0", port=8765) + server.bind_events(events) + + async with server: + print(f"Server running on ws://0.0.0.0:8765") + print(f"Connected clients: {server.client_count}") + + # Start scheduler + scheduler.start() + + # Main loop + while scheduler.state.value != "stopped": + scheduler.tick() + + # Broadcast current machine state + pos = machine.position + await server.broadcast_machine_state( + state=machine.state.value, + position={"x": pos.x, "y": pos.y, "z": pos.z}, + progress=machine.progress, + current_program=machine.current_program, + tool=machine.tool, + ) + + await asyncio.sleep(0.5) + + print("All jobs completed!") + +asyncio.run(main()) +``` + +## JavaScript Client Example + +Connect from a web browser: + +```javascript +const ws = new WebSocket('ws://localhost:8765'); + +ws.onopen = () => { + console.log('Connected to CNCKit'); + + // Send ping to keep alive + setInterval(() => { + ws.send(JSON.stringify({ type: 'ping' })); + }, 30000); +}; + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + console.log(`Received: ${msg.type}`, msg.data); + + switch (msg.type) { + case 'machine_state': + updateMachineDisplay(msg.data); + break; + case 'job_started': + showNotification(`Job started: ${msg.data.job.name}`); + break; + case 'job_completed': + showNotification(`Job completed: ${msg.data.job.name}`); + break; + case 'job_failed': + showError(`Job failed: ${msg.data.error}`); + break; + } +}; + +ws.onclose = () => { + console.log('Disconnected from CNCKit'); +}; +``` + +## Python Client Example + +Connect using websockets library: + +```python +import asyncio +import json +import websockets + +async def monitor(): + async with websockets.connect("ws://localhost:8765") as ws: + async for message in ws: + data = json.loads(message) + print(f"[{data['type']}] {data['data']}") + +asyncio.run(monitor()) +``` + +## Testing + +For testing without a real server: + +```python +from unittest.mock import AsyncMock +from cnckit.integrations.websocket import WebSocketServer + +# Create server with mock client +server = WebSocketServer() +mock_client = AsyncMock() +server._clients.add(mock_client) +server._running = True + +# Test broadcasting +await server.broadcast_machine_state(state="idle", progress=0.0) + +# Verify client received message +mock_client.send.assert_called_once() +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `host` | str | Server host | +| `port` | int | Server port | +| `is_running` | bool | Whether server is running | +| `client_count` | int | Number of connected clients | diff --git a/src/cnckit/integrations/websocket/__init__.py b/src/cnckit/integrations/websocket/__init__.py index e68a897..9dcf49d 100644 --- a/src/cnckit/integrations/websocket/__init__.py +++ b/src/cnckit/integrations/websocket/__init__.py @@ -5,33 +5,437 @@ Requires: pip install cnckit[websocket] """ +from __future__ import annotations + +import asyncio +import json +from datetime import datetime +from typing import TYPE_CHECKING, Any + +# Validate websockets is available on import +try: + import websockets + from websockets import serve +except ImportError: + raise ImportError( + "websockets is required for WebSocket integration. " + "Install with: pip install cnckit[websocket]" + ) from None + +if TYPE_CHECKING: + from collections.abc import Callable + + from cnckit.core import Event, EventEmitter, Job + + +# ============================================================================= +# Message Formatting +# ============================================================================= + + +def job_to_dict(job: Job) -> dict[str, Any]: + """Convert a Job instance to a dictionary for JSON serialization.""" + return { + "id": str(job.path), + "name": job.name or job.path.stem, + "path": str(job.path), + "priority": job.priority, + "status": job.status.value, + "estimated_time": job.estimated_time, + "created_at": job.created_at.isoformat(), + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error": job.error, + } + + +def create_message( + msg_type: str, + data: dict[str, Any] | None = None, +) -> str: + """ + Create a JSON message with standard format. + + Args: + msg_type: Message type identifier + data: Optional data payload + + Returns: + JSON string with type, timestamp, and data + """ + message = { + "type": msg_type, + "timestamp": datetime.now().isoformat(), + "data": data or {}, + } + return json.dumps(message) + + +# ============================================================================= +# WebSocket Server +# ============================================================================= + class WebSocketServer: """ WebSocket server for real-time cnckit updates. Streams machine state, job progress, and events to connected clients. + Supports multiple concurrent client connections. + + The server integrates with cnckit's EventEmitter to automatically + broadcast events to all connected clients. - Raises: - ImportError: If websockets is not installed + Example: + >>> import asyncio + >>> from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter + >>> from cnckit.integrations.websocket import WebSocketServer + >>> + >>> async def main(): + ... # Set up core components + ... machine = Machine(simulate=True) + ... queue = JobQueue() + ... events = EventEmitter() + ... scheduler = Scheduler(machine, queue, events) + ... + ... # Create and start WebSocket server + ... server = WebSocketServer(host="localhost", port=8765) + ... server.bind_events(events) + ... + ... async with server: + ... # Run scheduler in background + ... queue.add("part1.ngc") + ... scheduler.start() + ... while scheduler.state.value != "stopped": + ... scheduler.tick() + ... # Broadcast machine state + ... await server.broadcast_machine_state( + ... state=machine.state.value, + ... position={"x": machine.position.x, "y": machine.position.y}, + ... progress=machine.progress, + ... ) + ... await asyncio.sleep(1.0) + >>> + >>> asyncio.run(main()) """ - def __init__(self, host: str = "localhost", port: int = 8765): + def __init__( + self, + host: str = "localhost", + port: int = 8765, + ) -> None: """ Initialize WebSocket server. Args: - host: Host to bind to - port: Port to listen on + host: Host to bind to (default: "localhost") + port: Port to listen on (default: 8765) """ + self._host = host + self._port = port + self._server: Any | None = None + self._clients: set[Any] = set() # WebSocket connections + self._running = False + self._lock = asyncio.Lock() + + # Event binding + self._events: EventEmitter | None = None + self._event_handlers: list[tuple[Event, Callable[..., Any]]] = [] + + @property + def host(self) -> str: + """Get the server host.""" + return self._host + + @property + def port(self) -> int: + """Get the server port.""" + return self._port + + @property + def is_running(self) -> bool: + """Check if server is running.""" + return self._running + + @property + def client_count(self) -> int: + """Get number of connected clients.""" + return len(self._clients) + + # ------------------------------------------------------------------------- + # Server Lifecycle + # ------------------------------------------------------------------------- + + async def start(self) -> None: + """ + Start the WebSocket server. + + The server will accept connections and handle messages until + stop() is called. + + Raises: + RuntimeError: If server is already running + """ + if self._running: + raise RuntimeError("Server is already running") + + self._server = await serve( + self._handle_client, + self._host, + self._port, + ) + self._running = True + + async def stop(self) -> None: + """ + Stop the WebSocket server. + + Closes all client connections and stops accepting new ones. + """ + if not self._running: + return + + self._running = False + + # Close all client connections + async with self._lock: + for client in self._clients.copy(): + await client.close() + self._clients.clear() + + # Stop the server + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + # Unbind events + self._unbind_events() + + async def __aenter__(self) -> WebSocketServer: + """Async context manager entry.""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + await self.stop() + + # ------------------------------------------------------------------------- + # Client Handling + # ------------------------------------------------------------------------- + + async def _handle_client(self, websocket: Any) -> None: + """Handle a client connection.""" + async with self._lock: + self._clients.add(websocket) + try: - import websockets # noqa: F401 - except ImportError: - raise ImportError( - "websockets is required for WebSocket integration. " - "Install with: pip install cnckit[websocket]" + # Send welcome message + await websocket.send( + create_message("connected", {"message": "Connected to CNCKit"}) ) - raise NotImplementedError( - "WebSocket integration will be implemented in Phase 2" - ) + # Keep connection alive and handle incoming messages + async for message in websocket: + await self._handle_message(websocket, message) + except websockets.exceptions.ConnectionClosed: + pass + finally: + async with self._lock: + self._clients.discard(websocket) + + async def _handle_message( + self, + websocket: Any, + message: str | bytes, + ) -> None: + """ + Handle incoming message from client. + + Override this method to add custom message handling. + + Args: + websocket: The client connection + message: The received message + """ + # Default: echo back a pong for ping messages + try: + if isinstance(message, bytes): + message = message.decode("utf-8") + data = json.loads(message) + if data.get("type") == "ping": + await websocket.send(create_message("pong")) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + + # ------------------------------------------------------------------------- + # Broadcasting + # ------------------------------------------------------------------------- + + async def broadcast(self, message: str) -> None: + """ + Broadcast a message to all connected clients. + + Args: + message: JSON message string to broadcast + """ + if not self._clients: + return + + async with self._lock: + # Send to all clients, removing any that fail + disconnected: set[Any] = set() + for client in self._clients: + try: + await client.send(message) + except websockets.exceptions.ConnectionClosed: + disconnected.add(client) + + self._clients -= disconnected + + async def broadcast_machine_state( + self, + state: str, + position: dict[str, float] | None = None, + progress: float = 0.0, + current_program: str | None = None, + tool: int = 0, + ) -> None: + """ + Broadcast machine state update to all clients. + + Args: + state: Machine state (idle, running, etc.) + position: Position dict with x, y, z keys + progress: Program progress (0.0 to 1.0) + current_program: Path to current program + tool: Current tool number + """ + data = { + "state": state, + "position": position or {"x": 0.0, "y": 0.0, "z": 0.0}, + "progress": progress, + "current_program": current_program, + "tool": tool, + } + await self.broadcast(create_message("machine_state", data)) + + async def broadcast_job_started(self, job: Job) -> None: + """Broadcast job started event to all clients.""" + await self.broadcast(create_message("job_started", {"job": job_to_dict(job)})) + + async def broadcast_job_completed(self, job: Job) -> None: + """Broadcast job completed event to all clients.""" + await self.broadcast(create_message("job_completed", {"job": job_to_dict(job)})) + + async def broadcast_job_failed(self, job: Job, error: str | None = None) -> None: + """Broadcast job failed event to all clients.""" + data = {"job": job_to_dict(job), "error": error or job.error} + await self.broadcast(create_message("job_failed", data)) + + async def broadcast_queue_empty(self) -> None: + """Broadcast queue empty event to all clients.""" + await self.broadcast(create_message("queue_empty")) + + async def broadcast_scheduler_state( + self, + state: str, + current_job: Job | None = None, + ) -> None: + """ + Broadcast scheduler state update to all clients. + + Args: + state: Scheduler state (stopped, running, paused) + current_job: Currently executing job, if any + """ + data = { + "state": state, + "current_job": job_to_dict(current_job) if current_job else None, + } + await self.broadcast(create_message("scheduler_state", data)) + + # ------------------------------------------------------------------------- + # Event Binding + # ------------------------------------------------------------------------- + + def bind_events(self, events: EventEmitter) -> None: + """ + Bind to an EventEmitter to automatically broadcast events. + + When bound, the server will broadcast messages for: + - JOB_STARTED -> job_started message + - JOB_COMPLETED -> job_completed message + - JOB_FAILED -> job_failed message + - QUEUE_EMPTY -> queue_empty message + + Note: Event handlers schedule broadcasts as async tasks since + EventEmitter callbacks are synchronous. + + Args: + events: EventEmitter instance to listen to + """ + from cnckit.core import Event + + self._events = events + + def get_loop() -> asyncio.AbstractEventLoop | None: + """Get the running event loop, or None if not running.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + # Define handlers that schedule async broadcasts + def on_job_started(job: Job) -> None: + loop = get_loop() + if loop is not None: + loop.create_task(self.broadcast_job_started(job)) + + def on_job_completed(job: Job) -> None: + loop = get_loop() + if loop is not None: + loop.create_task(self.broadcast_job_completed(job)) + + def on_job_failed(job: Job, error: str | None = None) -> None: + loop = get_loop() + if loop is not None: + loop.create_task(self.broadcast_job_failed(job, error)) + + def on_queue_empty() -> None: + loop = get_loop() + if loop is not None: + loop.create_task(self.broadcast_queue_empty()) + + # Register handlers + handlers: list[tuple[Event, Callable[..., Any]]] = [ + (Event.JOB_STARTED, on_job_started), + (Event.JOB_COMPLETED, on_job_completed), + (Event.JOB_FAILED, on_job_failed), + (Event.QUEUE_EMPTY, on_queue_empty), + ] + + for event, handler in handlers: + events.on(event, handler) + self._event_handlers.append((event, handler)) + + def _unbind_events(self) -> None: + """Remove event handlers from the EventEmitter.""" + if self._events is not None: + for event, handler in self._event_handlers: + self._events.off(event, handler) + self._event_handlers.clear() + self._events = None + + +__all__ = [ + "WebSocketServer", + "create_message", + "job_to_dict", +] diff --git a/tests/integrations/test_websocket.py b/tests/integrations/test_websocket.py new file mode 100644 index 0000000..2aa4beb --- /dev/null +++ b/tests/integrations/test_websocket.py @@ -0,0 +1,512 @@ +"""Tests for WebSocket integration.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +# Check if websockets is available for testing +try: + import websockets + + WEBSOCKETS_AVAILABLE = True +except ImportError: + WEBSOCKETS_AVAILABLE = False + + +class TestWebSocketWithoutWebsockets: + """Tests that work without websockets installed.""" + + def test_import_raises_without_websockets(self): + """Import should raise ImportError if websockets not installed.""" + if WEBSOCKETS_AVAILABLE: + pytest.skip("websockets is installed, cannot test ImportError") + + with pytest.raises(ImportError, match="pip install cnckit\\[websocket\\]"): + from cnckit.integrations.websocket import WebSocketServer # noqa: F401 + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestMessageFormatting: + """Tests for message creation functions.""" + + def test_create_message_basic(self): + """create_message creates valid JSON with type and timestamp.""" + from cnckit.integrations.websocket import create_message + + msg = create_message("test_type") + parsed = json.loads(msg) + + assert parsed["type"] == "test_type" + assert "timestamp" in parsed + assert "T" in parsed["timestamp"] # ISO format + assert parsed["data"] == {} + + def test_create_message_with_data(self): + """create_message includes data payload.""" + from cnckit.integrations.websocket import create_message + + data = {"key": "value", "number": 42} + msg = create_message("test_type", data) + parsed = json.loads(msg) + + assert parsed["data"] == data + + def test_job_to_dict(self): + """job_to_dict converts Job to dictionary.""" + from cnckit.core import Job + from cnckit.integrations.websocket import job_to_dict + + job = Job(path=Path("/test/part.ngc"), priority=5, name="Test Part") + result = job_to_dict(job) + + assert result["id"] == "/test/part.ngc" + assert result["name"] == "Test Part" + assert result["path"] == "/test/part.ngc" + assert result["priority"] == 5 + assert result["status"] == "pending" + assert "created_at" in result + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketServerInit: + """Tests for WebSocketServer initialization.""" + + def test_init_with_defaults(self): + """WebSocketServer initializes with default values.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + + assert server.host == "localhost" + assert server.port == 8765 + assert server.is_running is False + assert server.client_count == 0 + + def test_init_with_custom_values(self): + """WebSocketServer accepts custom host and port.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(host="0.0.0.0", port=9000) + + assert server.host == "0.0.0.0" + assert server.port == 9000 + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketServerLifecycle: + """Tests for server start/stop lifecycle.""" + + @pytest.mark.asyncio + async def test_start_sets_running_flag(self): + """start() sets is_running to True.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(port=18765) + + # serve() is an async function that returns a server object + async def mock_serve(*args, **kwargs): + return AsyncMock() + + with patch("cnckit.integrations.websocket.serve", side_effect=mock_serve): + await server.start() + + assert server.is_running is True + + await server.stop() + + @pytest.mark.asyncio + async def test_start_raises_if_already_running(self): + """start() raises RuntimeError if already running.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(port=18766) + + async def mock_serve(*args, **kwargs): + return AsyncMock() + + with patch("cnckit.integrations.websocket.serve", side_effect=mock_serve): + await server.start() + + with pytest.raises(RuntimeError, match="already running"): + await server.start() + + await server.stop() + + @pytest.mark.asyncio + async def test_stop_clears_running_flag(self): + """stop() sets is_running to False.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(port=18767) + + async def mock_serve(*args, **kwargs): + return AsyncMock() + + with patch("cnckit.integrations.websocket.serve", side_effect=mock_serve): + await server.start() + await server.stop() + + assert server.is_running is False + + @pytest.mark.asyncio + async def test_stop_is_idempotent(self): + """stop() can be called multiple times safely.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(port=18768) + + # Stop without starting - should not raise + await server.stop() + await server.stop() + + assert server.is_running is False + + @pytest.mark.asyncio + async def test_context_manager(self): + """Server works as async context manager.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(port=18769) + + async def mock_serve(*args, **kwargs): + return AsyncMock() + + with patch("cnckit.integrations.websocket.serve", side_effect=mock_serve): + async with server: + assert server.is_running is True + + assert server.is_running is False + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketBroadcast: + """Tests for broadcasting methods.""" + + @pytest.fixture + def server_with_mock_client(self): + """Create server with a mock client.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + mock_client = AsyncMock() + server._clients.add(mock_client) + server._running = True + return server, mock_client + + @pytest.mark.asyncio + async def test_broadcast_sends_to_client(self, server_with_mock_client): + """broadcast() sends message to connected client.""" + server, mock_client = server_with_mock_client + + await server.broadcast('{"type": "test"}') + + mock_client.send.assert_called_once_with('{"type": "test"}') + + @pytest.mark.asyncio + async def test_broadcast_to_multiple_clients(self): + """broadcast() sends to all connected clients.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + client1 = AsyncMock() + client2 = AsyncMock() + server._clients = {client1, client2} + server._running = True + + await server.broadcast('{"type": "test"}') + + client1.send.assert_called_once() + client2.send.assert_called_once() + + @pytest.mark.asyncio + async def test_broadcast_removes_disconnected_clients(self): + """broadcast() removes clients that raise ConnectionClosed.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + good_client = AsyncMock() + bad_client = AsyncMock() + bad_client.send.side_effect = websockets.exceptions.ConnectionClosed(None, None) + + server._clients = {good_client, bad_client} + server._running = True + + await server.broadcast('{"type": "test"}') + + # Bad client should be removed + assert bad_client not in server._clients + assert good_client in server._clients + + @pytest.mark.asyncio + async def test_broadcast_with_no_clients(self): + """broadcast() handles empty client list gracefully.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + server._running = True + + # Should not raise + await server.broadcast('{"type": "test"}') + + @pytest.mark.asyncio + async def test_broadcast_machine_state(self, server_with_mock_client): + """broadcast_machine_state sends formatted message.""" + server, mock_client = server_with_mock_client + + await server.broadcast_machine_state( + state="running", + position={"x": 10.0, "y": 20.0, "z": 5.0}, + progress=0.5, + current_program="/test.ngc", + tool=1, + ) + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["type"] == "machine_state" + assert payload["data"]["state"] == "running" + assert payload["data"]["progress"] == 0.5 + assert payload["data"]["tool"] == 1 + + @pytest.mark.asyncio + async def test_broadcast_job_started(self, server_with_mock_client): + """broadcast_job_started sends job data.""" + from cnckit.core import Job + + server, mock_client = server_with_mock_client + job = Job(path=Path("/test.ngc")) + + await server.broadcast_job_started(job) + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["type"] == "job_started" + assert payload["data"]["job"]["path"] == "/test.ngc" + + @pytest.mark.asyncio + async def test_broadcast_job_completed(self, server_with_mock_client): + """broadcast_job_completed sends job data.""" + from cnckit.core import Job + + server, mock_client = server_with_mock_client + job = Job(path=Path("/test.ngc")) + job.mark_completed() + + await server.broadcast_job_completed(job) + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["type"] == "job_completed" + + @pytest.mark.asyncio + async def test_broadcast_job_failed(self, server_with_mock_client): + """broadcast_job_failed sends job data with error.""" + from cnckit.core import Job + + server, mock_client = server_with_mock_client + job = Job(path=Path("/test.ngc")) + job.mark_failed("Tool broke") + + await server.broadcast_job_failed(job, error="Tool broke") + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["data"]["error"] == "Tool broke" + + @pytest.mark.asyncio + async def test_broadcast_queue_empty(self, server_with_mock_client): + """broadcast_queue_empty sends empty message.""" + server, mock_client = server_with_mock_client + + await server.broadcast_queue_empty() + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["type"] == "queue_empty" + + @pytest.mark.asyncio + async def test_broadcast_scheduler_state(self, server_with_mock_client): + """broadcast_scheduler_state sends state data.""" + from cnckit.core import Job + + server, mock_client = server_with_mock_client + job = Job(path=Path("/test.ngc")) + + await server.broadcast_scheduler_state(state="running", current_job=job) + + call_args = mock_client.send.call_args + payload = json.loads(call_args[0][0]) + + assert payload["type"] == "scheduler_state" + assert payload["data"]["state"] == "running" + assert payload["data"]["current_job"]["path"] == "/test.ngc" + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketEventBinding: + """Tests for event binding functionality.""" + + def test_bind_events_registers_handlers(self): + """bind_events registers event handlers.""" + from cnckit.core import Event, EventEmitter + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + events = EventEmitter() + + server.bind_events(events) + + # Check handlers are registered + assert len(events.listeners(Event.JOB_STARTED)) > 0 + assert len(events.listeners(Event.JOB_COMPLETED)) > 0 + assert len(events.listeners(Event.JOB_FAILED)) > 0 + assert len(events.listeners(Event.QUEUE_EMPTY)) > 0 + + def test_unbind_events_removes_handlers(self): + """_unbind_events removes all event handlers.""" + from cnckit.core import Event, EventEmitter + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + events = EventEmitter() + + server.bind_events(events) + server._unbind_events() + + # Handlers should be removed + assert len(events.listeners(Event.JOB_STARTED)) == 0 + assert len(events.listeners(Event.JOB_COMPLETED)) == 0 + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketClientHandling: + """Tests for client connection handling.""" + + @pytest.mark.asyncio + async def test_handle_client_adds_to_set(self): + """_handle_client adds client to clients set.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + + # Create a mock websocket that supports async iteration + class MockWebSocket: + def __init__(self): + self.send = AsyncMock() + + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + mock_ws = MockWebSocket() + + await server._handle_client(mock_ws) + + # Client should be removed after handler exits + assert mock_ws not in server._clients + + @pytest.mark.asyncio + async def test_handle_message_responds_to_ping(self): + """_handle_message responds to ping with pong.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + mock_ws = AsyncMock() + + await server._handle_message(mock_ws, '{"type": "ping"}') + + mock_ws.send.assert_called_once() + call_args = mock_ws.send.call_args + payload = json.loads(call_args[0][0]) + assert payload["type"] == "pong" + + @pytest.mark.asyncio + async def test_handle_message_ignores_invalid_json(self): + """_handle_message handles invalid JSON gracefully.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer() + mock_ws = AsyncMock() + + # Should not raise + await server._handle_message(mock_ws, "not valid json") + await server._handle_message(mock_ws, b"\xff\xfe") + + # No response sent for invalid messages + mock_ws.send.assert_not_called() + + +@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed") +class TestWebSocketIntegration: + """Integration tests with real server (using available ports).""" + + @pytest.mark.asyncio + async def test_real_server_starts_and_stops(self): + """Real server can start and stop.""" + from cnckit.integrations.websocket import WebSocketServer + + # Use a random high port to avoid conflicts + server = WebSocketServer(host="127.0.0.1", port=28765) + + await server.start() + assert server.is_running is True + + await server.stop() + assert server.is_running is False + + @pytest.mark.asyncio + async def test_real_client_connection(self): + """Real client can connect and receive messages.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(host="127.0.0.1", port=28766) + + async with server: + # Connect a client + async with websockets.connect("ws://127.0.0.1:28766") as client: + # Should receive welcome message + msg = await asyncio.wait_for(client.recv(), timeout=2.0) + data = json.loads(msg) + + assert data["type"] == "connected" + assert server.client_count == 1 + + # Give server time to detect disconnect + await asyncio.sleep(0.1) + assert server.client_count == 0 + + @pytest.mark.asyncio + async def test_broadcast_reaches_client(self): + """Broadcast message reaches connected client.""" + from cnckit.integrations.websocket import WebSocketServer + + server = WebSocketServer(host="127.0.0.1", port=28767) + + async with server, websockets.connect("ws://127.0.0.1:28767") as client: + # Consume welcome message + await client.recv() + + # Broadcast a message + await server.broadcast_machine_state( + state="idle", + progress=0.0, + ) + + # Client should receive it + msg = await asyncio.wait_for(client.recv(), timeout=2.0) + data = json.loads(msg) + + assert data["type"] == "machine_state" + assert data["data"]["state"] == "idle" From cbc9ad15c24ba479c07ea1765f6482986fd57ca7 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:22:03 -0800 Subject: [PATCH 05/11] feat(robot): add TCP and ROS2 robot interfaces --- docs/api/integrations/robot.md | 292 ++++++++- src/cnckit/integrations/robot/__init__.py | 706 +++++++++++++++++++++- tests/integrations/test_robot.py | 706 ++++++++++++++++++++++ 3 files changed, 1662 insertions(+), 42 deletions(-) create mode 100644 tests/integrations/test_robot.py diff --git a/docs/api/integrations/robot.md b/docs/api/integrations/robot.md index 16e3b85..7baf944 100644 --- a/docs/api/integrations/robot.md +++ b/docs/api/integrations/robot.md @@ -1,58 +1,300 @@ # Robot Integration -The robot integration provides coordination between CNC machines and robotic systems. +The Robot integration provides interfaces for coordinating CNC operations with robotic systems. Two interfaces are available: -## ROS2 Interface +- **TCPRobotClient**: Simple TCP socket client for robots with text-based protocols +- **ROS2Interface**: Full ROS2 node integration for ROS-based robots -### Installation - -ROS2 must be installed separately. See [ROS2 documentation](https://docs.ros.org/). +## TCPRobotClient ### Quick Start ```python -from cnckit.integrations.robot import ROS2Interface +from cnckit.integrations.robot import TCPRobotClient + +# Connect to robot +robot = TCPRobotClient("192.168.1.100", port=10000) +robot.connect() -interface = ROS2Interface(node_name="cnckit") -interface.spin() +# Send commands +robot.home() +robot.load_part("part_001") +status = robot.get_status() +print(f"Robot ready: {status.ready}") + +robot.disconnect() ``` ### API Reference -::: cnckit.integrations.robot.ROS2Interface +::: cnckit.integrations.robot.TCPRobotClient options: show_root_heading: true - heading_level: 4 + heading_level: 3 -## TCP Robot Client +### Connection Parameters -For robots with simple TCP command interfaces. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `host` | str | required | Robot hostname or IP address | +| `port` | int | required | Robot TCP port | +| `timeout` | float | 10.0 | Socket timeout in seconds | +| `terminator` | str | "\n" | Message terminator string | +| `encoding` | str | "utf-8" | Character encoding | -### Quick Start +### Standard Commands + +```python +# Motion commands +robot.home() # Return to home position +robot.move_to(x=10.0, y=20.0, z=5.0) # Move to position +robot.stop() # Emergency stop +robot.pause() # Pause motion +robot.resume() # Resume motion + +# Part handling +robot.load_part() # Load part into CNC +robot.load_part("part_001") # Load specific part +robot.unload_part() # Unload part from CNC + +# Tool operations +robot.tool_change(5) # Change to tool 5 +robot.inspect("visual") # Trigger inspection +``` + +### Custom Commands + +```python +# Send raw command +response = robot.send_command("CUSTOM_CMD arg1 arg2") + +# Send JSON command +response = robot.send_json({ + "command": "move", + "position": {"x": 10, "y": 20, "z": 5} +}) +``` + +### Robot Status + +```python +status = robot.get_status() + +print(f"Connected: {status.connected}") +print(f"Ready: {status.ready}") +print(f"Busy: {status.busy}") +print(f"Error: {status.error}") +print(f"Position: {status.position}") +``` + +### Event Binding for CNC Coordination + +Automatically coordinate robot actions with CNC events: ```python +from cnckit.core import EventEmitter from cnckit.integrations.robot import TCPRobotClient -robot = TCPRobotClient(host="192.168.1.100", port=10000) +events = EventEmitter() +robot = TCPRobotClient("192.168.1.100", 10000) robot.connect() -robot.send_command("MOVE X100 Y200") + +# Auto-load part when job starts, auto-unload when complete +robot.bind_events(events, auto_load=True, auto_unload=True) + +# Now when scheduler emits JOB_STARTED, robot.load_part() is called +# When JOB_COMPLETED is emitted, robot.unload_part() is called +``` + +## ROS2Interface + +The ROS2 interface requires ROS2 to be installed and sourced. It provides full pub/sub integration with ROS2 topics. + +### Installation + +1. Install ROS2 (see [ROS2 documentation](https://docs.ros.org/)) +2. Source the ROS2 setup: `source /opt/ros//setup.bash` + +### Quick Start + +```python +import rclpy +from cnckit.core import EventEmitter +from cnckit.integrations.robot import ROS2Interface + +rclpy.init() + +events = EventEmitter() +interface = ROS2Interface(node_name="cnckit_node") +interface.bind_events(events) + +# Publish machine state +interface.publish_machine_state( + state="running", + position={"x": 10.0, "y": 20.0, "z": 5.0}, + progress=0.5, +) + +# Run ROS2 event loop +rclpy.spin(interface.node) + +rclpy.shutdown() ``` ### API Reference -::: cnckit.integrations.robot.TCPRobotClient +::: cnckit.integrations.robot.ROS2Interface options: show_root_heading: true - heading_level: 4 + heading_level: 3 + +### Published Topics + +| Topic | Type | Description | +|-------|------|-------------| +| `/cnckit/machine_state` | std_msgs/String | Machine state updates (JSON) | +| `/cnckit/job_started` | std_msgs/String | Job started events (JSON) | +| `/cnckit/job_completed` | std_msgs/String | Job completed events (JSON) | +| `/cnckit/job_failed` | std_msgs/String | Job failed events (JSON) | +| `/cnckit/queue_empty` | std_msgs/String | Queue empty events (JSON) | + +### Subscribed Topics + +| Topic | Type | Description | +|-------|------|-------------| +| `/cnckit/commands` | std_msgs/String | Incoming commands | + +### Message Format + +All messages use JSON format: + +```json +{ + "type": "machine_state", + "timestamp": "2024-01-15T10:30:00.123456", + "data": { + "state": "running", + "position": {"x": 10.0, "y": 20.0, "z": 5.0}, + "progress": 0.5 + } +} +``` + +### Command Handling + +Register a callback for incoming commands: + +```python +def handle_command(command: str): + print(f"Received command: {command}") + if command == "PAUSE": + scheduler.pause() + elif command == "RESUME": + scheduler.start() + +interface.on_command(handle_command) +``` + +### Event Binding + +Automatically publish CNC events to ROS2 topics: + +```python +events = EventEmitter() +interface = ROS2Interface() +interface.bind_events(events) + +# Now when events are emitted, they're published to ROS2: +# Event.JOB_STARTED -> /cnckit/job_started +# Event.JOB_COMPLETED -> /cnckit/job_completed +# Event.JOB_FAILED -> /cnckit/job_failed +# Event.QUEUE_EMPTY -> /cnckit/queue_empty +``` + +## Complete Example: CNC-Robot Cell + +```python +import asyncio +from cnckit.core import Machine, JobQueue, Scheduler, EventEmitter +from cnckit.integrations.robot import TCPRobotClient + +async def main(): + # Set up CNC components + machine = Machine(simulate=True) + queue = JobQueue() + events = EventEmitter() + scheduler = Scheduler(machine, queue, events) + + # Connect to robot + robot = TCPRobotClient("192.168.1.100", 10000) + robot.connect() + robot.bind_events(events, auto_load=True, auto_unload=True) + + # Add jobs + queue.add("/jobs/part1.ngc") + queue.add("/jobs/part2.ngc") + + # Run scheduler + scheduler.start() + while scheduler.state.value != "stopped": + scheduler.tick() + await asyncio.sleep(0.1) + + # Clean up + robot.disconnect() + print("All parts completed!") + +asyncio.run(main()) +``` + +## Helper Types + +### RobotCommand + +Enum of standard robot commands: + +```python +from cnckit.integrations.robot import RobotCommand + +RobotCommand.LOAD_PART # "load_part" +RobotCommand.UNLOAD_PART # "unload_part" +RobotCommand.TOOL_CHANGE # "tool_change" +RobotCommand.INSPECT # "inspect" +RobotCommand.HOME # "home" +RobotCommand.PAUSE # "pause" +RobotCommand.RESUME # "resume" +RobotCommand.STOP # "stop" +``` + +### RobotStatus + +Dataclass for robot status: + +```python +from cnckit.integrations.robot import RobotStatus + +status = RobotStatus( + connected=True, + ready=True, + busy=False, + error=None, + position={"x": 0.0, "y": 0.0, "z": 100.0} +) +``` + +## Properties -## Use Cases +### TCPRobotClient -!!! note "Coming in Phase 2" - Robot integration will be implemented in Phase 2. +| Property | Type | Description | +|----------|------|-------------| +| `host` | str | Robot hostname | +| `port` | int | Robot port | +| `is_connected` | bool | Connection status | -Planned features: +### ROS2Interface -- **Part Loading**: Signal robot to load/unload parts -- **Tool Changes**: Coordinate tool changes with robot arm -- **Quality Inspection**: Trigger robot-mounted inspection -- **Multi-Machine Coordination**: Orchestrate multiple CNC machines with robots +| Property | Type | Description | +|----------|------|-------------| +| `node` | Node | ROS2 node for spinning | +| `node_name` | str | Node name | diff --git a/src/cnckit/integrations/robot/__init__.py b/src/cnckit/integrations/robot/__init__.py index 2570458..38a2fda 100644 --- a/src/cnckit/integrations/robot/__init__.py +++ b/src/cnckit/integrations/robot/__init__.py @@ -2,51 +2,723 @@ Robot interfaces for ROS2 and TCP-based robots. Provides coordination between CNC machine and robotic systems. -Requires: pip install cnckit[robot] + +Two interfaces are available: +- TCPRobotClient: Simple TCP socket client for robots with text-based protocols +- ROS2Interface: Full ROS2 node integration (requires ROS2 installation) """ +from __future__ import annotations + +import contextlib +import json +import socket +import threading +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from cnckit.core import Event, EventEmitter, Job + + +# ============================================================================= +# Common Types and Utilities +# ============================================================================= + + +class RobotCommand(Enum): + """Standard robot commands.""" + + LOAD_PART = "load_part" + UNLOAD_PART = "unload_part" + TOOL_CHANGE = "tool_change" + INSPECT = "inspect" + HOME = "home" + PAUSE = "pause" + RESUME = "resume" + STOP = "stop" + + +@dataclass +class RobotStatus: + """Robot status information.""" + + connected: bool = False + ready: bool = False + busy: bool = False + error: str | None = None + position: dict[str, float] | None = None + + +def create_message( + msg_type: str, + data: dict[str, Any] | None = None, +) -> str: + """ + Create a JSON message with standard format. + + Args: + msg_type: Message type identifier + data: Optional data payload + + Returns: + JSON string with type, timestamp, and data + """ + message = { + "type": msg_type, + "timestamp": datetime.now().isoformat(), + "data": data or {}, + } + return json.dumps(message) + + +def job_to_dict(job: Job) -> dict[str, Any]: + """Convert a Job instance to a dictionary for serialization.""" + return { + "id": str(job.path), + "name": job.name or job.path.stem, + "path": str(job.path), + "priority": job.priority, + "status": job.status.value, + } + + +# ============================================================================= +# TCP Robot Client +# ============================================================================= + + +class TCPRobotClient: + """ + TCP client for simple robot protocols. + + Sends commands to robots over TCP sockets using a simple text-based + protocol. Suitable for industrial robots with basic command interfaces. + + The client supports: + - Synchronous command/response communication + - Configurable message terminators + - Automatic reconnection + - Event binding for CNC coordination + + Example: + >>> from cnckit.integrations.robot import TCPRobotClient + >>> + >>> robot = TCPRobotClient("192.168.1.100", port=10000) + >>> robot.connect() + >>> + >>> # Send command and get response + >>> response = robot.send_command("STATUS") + >>> print(response) + >>> + >>> # Use predefined commands + >>> robot.load_part(part_id="part_001") + >>> robot.unload_part() + >>> + >>> robot.disconnect() + + Protocol: + Commands are sent as text with a configurable terminator (default: newline). + Responses are read until the terminator is received. + """ + + def __init__( + self, + host: str, + port: int, + timeout: float = 10.0, + terminator: str = "\n", + encoding: str = "utf-8", + ) -> None: + """ + Initialize TCP connection parameters. + + Args: + host: Robot hostname or IP address + port: Robot TCP port + timeout: Socket timeout in seconds (default: 10.0) + terminator: Message terminator string (default: newline) + encoding: Character encoding for messages (default: utf-8) + """ + self._host = host + self._port = port + self._timeout = timeout + self._terminator = terminator + self._encoding = encoding + self._socket: socket.socket | None = None + self._connected = False + self._lock = threading.Lock() + + # Event binding + self._events: EventEmitter | None = None + self._event_handlers: list[tuple[Event, Callable[..., Any]]] = [] + + @property + def host(self) -> str: + """Get robot hostname.""" + return self._host + + @property + def port(self) -> int: + """Get robot port.""" + return self._port + + @property + def is_connected(self) -> bool: + """Check if connected to robot.""" + return self._connected + + # ------------------------------------------------------------------------- + # Connection Management + # ------------------------------------------------------------------------- + + def connect(self) -> None: + """ + Connect to the robot. + + Raises: + ConnectionError: If connection fails + """ + if self._connected: + return + + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(self._timeout) + self._socket.connect((self._host, self._port)) + self._connected = True + except OSError as e: + self._socket = None + raise ConnectionError(f"Failed to connect to robot: {e}") from e + + def disconnect(self) -> None: + """Disconnect from the robot.""" + self._unbind_events() + if self._socket is not None: + with contextlib.suppress(OSError): + self._socket.close() + self._socket = None + self._connected = False + + def reconnect(self) -> None: + """Reconnect to the robot.""" + self.disconnect() + self.connect() + + # ------------------------------------------------------------------------- + # Communication + # ------------------------------------------------------------------------- + + def send_command( + self, + command: str, + wait_response: bool = True, + ) -> str | None: + """ + Send a command to the robot. + + Args: + command: Command string to send + wait_response: Whether to wait for a response + + Returns: + Response string if wait_response is True, else None + + Raises: + ConnectionError: If not connected + TimeoutError: If response times out + """ + if not self._connected or self._socket is None: + raise ConnectionError("Not connected to robot") + + with self._lock: + # Send command with terminator + message = command + self._terminator + self._socket.sendall(message.encode(self._encoding)) + + if not wait_response: + return None + + # Read response until terminator + response = self._read_response() + return response + + def _read_response(self) -> str: + """Read response from robot until terminator.""" + if self._socket is None: + raise ConnectionError("Not connected") + + buffer = "" + while True: + try: + chunk = self._socket.recv(1024).decode(self._encoding) + if not chunk: + raise ConnectionError("Connection closed by robot") + buffer += chunk + if self._terminator in buffer: + # Return everything before the terminator + response, _ = buffer.split(self._terminator, 1) + return response.strip() + except TimeoutError as e: + raise TimeoutError("Response timeout") from e + + def send_json( + self, + data: dict[str, Any], + wait_response: bool = True, + ) -> dict[str, Any] | None: + """ + Send JSON command to the robot. + + Args: + data: Dictionary to send as JSON + wait_response: Whether to wait for a response + + Returns: + Parsed JSON response if wait_response is True, else None + """ + command = json.dumps(data) + response = self.send_command(command, wait_response) + if response is None: + return None + try: + result: dict[str, Any] = json.loads(response) + return result + except json.JSONDecodeError: + return {"raw": response} + + # ------------------------------------------------------------------------- + # Standard Robot Commands + # ------------------------------------------------------------------------- + + def get_status(self) -> RobotStatus: + """ + Get robot status. + + Returns: + RobotStatus with current state + """ + try: + response = self.send_command("STATUS") + if response is None: + return RobotStatus(connected=True) + + # Try to parse as JSON + try: + data = json.loads(response) + return RobotStatus( + connected=True, + ready=data.get("ready", False), + busy=data.get("busy", False), + error=data.get("error"), + position=data.get("position"), + ) + except json.JSONDecodeError: + # Simple text response + return RobotStatus( + connected=True, + ready="READY" in response.upper(), + busy="BUSY" in response.upper(), + ) + except (ConnectionError, TimeoutError) as e: + return RobotStatus(connected=False, error=str(e)) + + def home(self) -> str | None: + """Send robot to home position.""" + return self.send_command("HOME") + + def stop(self) -> str | None: + """Emergency stop the robot.""" + return self.send_command("STOP") + + def pause(self) -> str | None: + """Pause robot motion.""" + return self.send_command("PAUSE") + + def resume(self) -> str | None: + """Resume robot motion.""" + return self.send_command("RESUME") + + def load_part(self, part_id: str | None = None) -> str | None: + """ + Signal robot to load a part into the CNC. + + Args: + part_id: Optional part identifier + + Returns: + Robot response + """ + if part_id: + return self.send_command(f"LOAD_PART {part_id}") + return self.send_command("LOAD_PART") + + def unload_part(self, part_id: str | None = None) -> str | None: + """ + Signal robot to unload a part from the CNC. + + Args: + part_id: Optional part identifier + + Returns: + Robot response + """ + if part_id: + return self.send_command(f"UNLOAD_PART {part_id}") + return self.send_command("UNLOAD_PART") + + def tool_change(self, tool_number: int) -> str | None: + """ + Signal robot to perform tool change. + + Args: + tool_number: Tool number to change to + + Returns: + Robot response + """ + return self.send_command(f"TOOL_CHANGE {tool_number}") + + def inspect(self, inspection_type: str = "visual") -> str | None: + """ + Trigger robot-mounted inspection. + + Args: + inspection_type: Type of inspection (visual, dimensional, etc.) + + Returns: + Robot response (may include inspection results) + """ + return self.send_command(f"INSPECT {inspection_type}") + + def move_to(self, x: float, y: float, z: float) -> str | None: + """ + Move robot to position. + + Args: + x: X coordinate + y: Y coordinate + z: Z coordinate + + Returns: + Robot response + """ + return self.send_command(f"MOVE X{x} Y{y} Z{z}") + + # ------------------------------------------------------------------------- + # Event Binding + # ------------------------------------------------------------------------- + + def bind_events( + self, + events: EventEmitter, + auto_load: bool = False, + auto_unload: bool = False, + ) -> None: + """ + Bind to EventEmitter for CNC coordination. + + When bound, the robot will respond to CNC events: + - JOB_STARTED: Optionally load part + - JOB_COMPLETED: Optionally unload part + + Args: + events: EventEmitter to listen to + auto_load: Automatically load part on job start + auto_unload: Automatically unload part on job complete + """ + from cnckit.core import Event + + self._events = events + + def on_job_started(job: Job) -> None: + if auto_load and self._connected: + self.load_part(str(job.path)) + + def on_job_completed(job: Job) -> None: + if auto_unload and self._connected: + self.unload_part(str(job.path)) + + handlers: list[tuple[Event, Callable[..., Any]]] = [ + (Event.JOB_STARTED, on_job_started), + (Event.JOB_COMPLETED, on_job_completed), + ] + + for event, handler in handlers: + events.on(event, handler) + self._event_handlers.append((event, handler)) + + def _unbind_events(self) -> None: + """Remove event handlers.""" + if self._events is not None: + for event, handler in self._event_handlers: + self._events.off(event, handler) + self._event_handlers.clear() + self._events = None + + +# ============================================================================= +# ROS2 Interface +# ============================================================================= + class ROS2Interface: """ ROS2 interface for robot coordination. Publishes CNC events to ROS2 topics and subscribes to robot commands. + Requires ROS2 to be installed and sourced. + + Topics Published: + - /cnckit/machine_state (std_msgs/String): Machine state updates + - /cnckit/job_started (std_msgs/String): Job started events + - /cnckit/job_completed (std_msgs/String): Job completed events + - /cnckit/job_failed (std_msgs/String): Job failed events - Raises: - ImportError: If rclpy is not installed + Topics Subscribed: + - /cnckit/commands (std_msgs/String): Incoming commands + + Example: + >>> import rclpy + >>> from cnckit.integrations.robot import ROS2Interface + >>> + >>> rclpy.init() + >>> interface = ROS2Interface(node_name="cnckit_node") + >>> interface.bind_events(events) + >>> + >>> # Publish machine state + >>> interface.publish_machine_state("running") + >>> + >>> # Spin to process callbacks + >>> rclpy.spin(interface.node) + >>> + >>> rclpy.shutdown() + + Note: + ROS2 must be installed and the environment sourced before using + this interface. See https://docs.ros.org/ for installation. """ - def __init__(self, node_name: str = "cnckit"): + def __init__( + self, + node_name: str = "cnckit", + namespace: str = "", + ) -> None: """ Initialize ROS2 node. Args: - node_name: ROS2 node name + node_name: ROS2 node name (default: "cnckit") + namespace: Optional ROS2 namespace + + Raises: + ImportError: If rclpy is not installed """ try: import rclpy # noqa: F401 + from rclpy.node import Node + from std_msgs.msg import String except ImportError: raise ImportError( "rclpy is required for ROS2 integration. " "Install ROS2 and source the setup script." - ) + ) from None - raise NotImplementedError("ROS2 integration will be implemented in Phase 2") + # Store imports for later use + self._String = String + # Create node + self._node = Node(node_name, namespace=namespace) + self._node_name = node_name -class TCPRobotClient: - """ - TCP client for simple robot protocols. + # Create publishers + self._pub_machine_state = self._node.create_publisher( + String, "cnckit/machine_state", 10 + ) + self._pub_job_started = self._node.create_publisher( + String, "cnckit/job_started", 10 + ) + self._pub_job_completed = self._node.create_publisher( + String, "cnckit/job_completed", 10 + ) + self._pub_job_failed = self._node.create_publisher( + String, "cnckit/job_failed", 10 + ) + self._pub_queue_empty = self._node.create_publisher( + String, "cnckit/queue_empty", 10 + ) - Sends commands to robots over TCP sockets. - """ + # Create subscriber for commands + self._command_callback: Callable[[str], None] | None = None + self._sub_commands = self._node.create_subscription( + String, + "cnckit/commands", + self._on_command, + 10, + ) + + # Event binding + self._events: EventEmitter | None = None + self._event_handlers: list[tuple[Event, Callable[..., Any]]] = [] + + @property + def node(self) -> Any: + """Get the ROS2 node for spinning.""" + return self._node + + @property + def node_name(self) -> str: + """Get the node name.""" + return self._node_name + + # ------------------------------------------------------------------------- + # Publishing + # ------------------------------------------------------------------------- - def __init__(self, host: str, port: int): + def _publish(self, publisher: Any, message: str) -> None: + """Publish a string message.""" + msg = self._String() + msg.data = message + publisher.publish(msg) + + def publish_machine_state( + self, + state: str, + position: dict[str, float] | None = None, + progress: float = 0.0, + ) -> None: """ - Initialize TCP connection to robot. + Publish machine state to ROS2 topic. Args: - host: Robot hostname or IP - port: Robot TCP port + state: Machine state (idle, running, etc.) + position: Optional position dict + progress: Program progress (0.0 to 1.0) + """ + data = { + "state": state, + "position": position or {}, + "progress": progress, + } + self._publish( + self._pub_machine_state, + create_message("machine_state", data), + ) + + def publish_job_started(self, job: Job) -> None: + """Publish job started event.""" + self._publish( + self._pub_job_started, + create_message("job_started", {"job": job_to_dict(job)}), + ) + + def publish_job_completed(self, job: Job) -> None: + """Publish job completed event.""" + self._publish( + self._pub_job_completed, + create_message("job_completed", {"job": job_to_dict(job)}), + ) + + def publish_job_failed(self, job: Job, error: str | None = None) -> None: + """Publish job failed event.""" + data = {"job": job_to_dict(job), "error": error or job.error} + self._publish( + self._pub_job_failed, + create_message("job_failed", data), + ) + + def publish_queue_empty(self) -> None: + """Publish queue empty event.""" + self._publish( + self._pub_queue_empty, + create_message("queue_empty"), + ) + + # ------------------------------------------------------------------------- + # Command Handling + # ------------------------------------------------------------------------- + + def _on_command(self, msg: Any) -> None: + """Handle incoming command messages.""" + if self._command_callback is not None: + self._command_callback(msg.data) + + def on_command(self, callback: Callable[[str], None]) -> None: """ - raise NotImplementedError("TCP robot client will be implemented in Phase 2") + Register callback for incoming commands. + + Args: + callback: Function to call with command string + """ + self._command_callback = callback + + # ------------------------------------------------------------------------- + # Event Binding + # ------------------------------------------------------------------------- + + def bind_events(self, events: EventEmitter) -> None: + """ + Bind to EventEmitter to automatically publish events. + + When bound, the interface will publish to ROS2 topics for: + - JOB_STARTED -> /cnckit/job_started + - JOB_COMPLETED -> /cnckit/job_completed + - JOB_FAILED -> /cnckit/job_failed + - QUEUE_EMPTY -> /cnckit/queue_empty + + Args: + events: EventEmitter to listen to + """ + from cnckit.core import Event + + self._events = events + + def on_job_started(job: Job) -> None: + self.publish_job_started(job) + + def on_job_completed(job: Job) -> None: + self.publish_job_completed(job) + + def on_job_failed(job: Job, error: str | None = None) -> None: + self.publish_job_failed(job, error) + + def on_queue_empty() -> None: + self.publish_queue_empty() + + handlers: list[tuple[Event, Callable[..., Any]]] = [ + (Event.JOB_STARTED, on_job_started), + (Event.JOB_COMPLETED, on_job_completed), + (Event.JOB_FAILED, on_job_failed), + (Event.QUEUE_EMPTY, on_queue_empty), + ] + + for event, handler in handlers: + events.on(event, handler) + self._event_handlers.append((event, handler)) + + def unbind_events(self) -> None: + """Remove event handlers.""" + if self._events is not None: + for event, handler in self._event_handlers: + self._events.off(event, handler) + self._event_handlers.clear() + self._events = None + + def destroy(self) -> None: + """Clean up ROS2 resources.""" + self.unbind_events() + self._node.destroy_node() + + +__all__ = [ + "ROS2Interface", + "RobotCommand", + "RobotStatus", + "TCPRobotClient", + "create_message", + "job_to_dict", +] diff --git a/tests/integrations/test_robot.py b/tests/integrations/test_robot.py new file mode 100644 index 0000000..6946738 --- /dev/null +++ b/tests/integrations/test_robot.py @@ -0,0 +1,706 @@ +"""Tests for Robot integration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cnckit.integrations.robot import ( + RobotCommand, + RobotStatus, + TCPRobotClient, + create_message, + job_to_dict, +) + + +class TestRobotCommand: + """Tests for RobotCommand enum.""" + + def test_command_values(self): + """RobotCommand has expected values.""" + assert RobotCommand.LOAD_PART.value == "load_part" + assert RobotCommand.UNLOAD_PART.value == "unload_part" + assert RobotCommand.TOOL_CHANGE.value == "tool_change" + assert RobotCommand.HOME.value == "home" + assert RobotCommand.STOP.value == "stop" + assert RobotCommand.PAUSE.value == "pause" + assert RobotCommand.RESUME.value == "resume" + + +class TestRobotStatus: + """Tests for RobotStatus dataclass.""" + + def test_default_values(self): + """RobotStatus has correct defaults.""" + status = RobotStatus() + + assert status.connected is False + assert status.ready is False + assert status.busy is False + assert status.error is None + assert status.position is None + + def test_custom_values(self): + """RobotStatus accepts custom values.""" + status = RobotStatus( + connected=True, + ready=True, + busy=False, + error="Test error", + position={"x": 1.0, "y": 2.0, "z": 3.0}, + ) + + assert status.connected is True + assert status.ready is True + assert status.error == "Test error" + assert status.position == {"x": 1.0, "y": 2.0, "z": 3.0} + + +class TestMessageFormatting: + """Tests for message creation functions.""" + + def test_create_message_basic(self): + """create_message creates valid JSON with type and timestamp.""" + msg = create_message("test_type") + parsed = json.loads(msg) + + assert parsed["type"] == "test_type" + assert "timestamp" in parsed + assert "T" in parsed["timestamp"] # ISO format + assert parsed["data"] == {} + + def test_create_message_with_data(self): + """create_message includes data payload.""" + data = {"key": "value", "number": 42} + msg = create_message("test_type", data) + parsed = json.loads(msg) + + assert parsed["data"] == data + + def test_job_to_dict(self): + """job_to_dict converts Job to dictionary.""" + from cnckit.core import Job + + job = Job(path=Path("/test/part.ngc"), priority=5, name="Test Part") + result = job_to_dict(job) + + assert result["id"] == "/test/part.ngc" + assert result["name"] == "Test Part" + assert result["path"] == "/test/part.ngc" + assert result["priority"] == 5 + assert result["status"] == "pending" + + +class TestTCPRobotClientInit: + """Tests for TCPRobotClient initialization.""" + + def test_init_with_defaults(self): + """TCPRobotClient initializes with specified host and port.""" + client = TCPRobotClient("192.168.1.100", 10000) + + assert client.host == "192.168.1.100" + assert client.port == 10000 + assert client.is_connected is False + + def test_init_with_custom_options(self): + """TCPRobotClient accepts custom timeout and terminator.""" + client = TCPRobotClient( + "192.168.1.100", + 10000, + timeout=5.0, + terminator="\r\n", + encoding="ascii", + ) + + assert client._timeout == 5.0 + assert client._terminator == "\r\n" + assert client._encoding == "ascii" + + +class TestTCPRobotClientConnection: + """Tests for TCPRobotClient connection management.""" + + def test_connect_success(self): + """connect() establishes socket connection.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + with patch("socket.socket", return_value=mock_socket): + client.connect() + + assert client.is_connected is True + mock_socket.connect.assert_called_once_with(("192.168.1.100", 10000)) + mock_socket.settimeout.assert_called_once_with(10.0) + + def test_connect_already_connected(self): + """connect() does nothing if already connected.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + with patch("socket.socket", return_value=mock_socket): + client.connect() + client.connect() # Second call + + # connect should only be called once + assert mock_socket.connect.call_count == 1 + + def test_connect_failure(self): + """connect() raises ConnectionError on failure.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + mock_socket.connect.side_effect = OSError("Connection refused") + with ( + patch("socket.socket", return_value=mock_socket), + pytest.raises(ConnectionError, match="Failed to connect to robot"), + ): + client.connect() + + assert client.is_connected is False + + def test_disconnect(self): + """disconnect() closes socket and clears state.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + with patch("socket.socket", return_value=mock_socket): + client.connect() + client.disconnect() + + assert client.is_connected is False + mock_socket.close.assert_called_once() + + def test_disconnect_handles_socket_error(self): + """disconnect() handles socket close errors gracefully.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + mock_socket.close.side_effect = OSError("Socket error") + with patch("socket.socket", return_value=mock_socket): + client.connect() + # Should not raise + client.disconnect() + + assert client.is_connected is False + + def test_reconnect(self): + """reconnect() disconnects and connects again.""" + client = TCPRobotClient("192.168.1.100", 10000) + + mock_socket = MagicMock() + with patch("socket.socket", return_value=mock_socket): + client.connect() + client.reconnect() + + # Should have connected twice (first connect, then reconnect) + assert mock_socket.connect.call_count == 2 + + +class TestTCPRobotClientCommunication: + """Tests for TCPRobotClient command sending.""" + + @pytest.fixture + def connected_client(self): + """Create a connected client with mock socket.""" + client = TCPRobotClient("192.168.1.100", 10000) + mock_socket = MagicMock() + mock_socket.recv.return_value = b"OK\n" + + with patch("socket.socket", return_value=mock_socket): + client.connect() + + return client, mock_socket + + def test_send_command_not_connected(self): + """send_command raises ConnectionError if not connected.""" + client = TCPRobotClient("192.168.1.100", 10000) + + with pytest.raises(ConnectionError, match="Not connected"): + client.send_command("STATUS") + + def test_send_command_success(self, connected_client): + """send_command sends command and returns response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b"OK\n" + + response = client.send_command("STATUS") + + assert response == "OK" + mock_socket.sendall.assert_called_with(b"STATUS\n") + + def test_send_command_no_wait(self, connected_client): + """send_command with wait_response=False returns None.""" + client, mock_socket = connected_client + + response = client.send_command("GO", wait_response=False) + + assert response is None + mock_socket.sendall.assert_called_with(b"GO\n") + mock_socket.recv.assert_not_called() + + def test_send_command_timeout(self, connected_client): + """send_command raises TimeoutError on socket timeout.""" + client, mock_socket = connected_client + mock_socket.recv.side_effect = TimeoutError("timed out") + + with pytest.raises(TimeoutError, match="Response timeout"): + client.send_command("STATUS") + + def test_send_command_connection_closed(self, connected_client): + """send_command raises ConnectionError if connection closed.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b"" # Empty = connection closed + + with pytest.raises(ConnectionError, match="Connection closed"): + client.send_command("STATUS") + + def test_send_json(self, connected_client): + """send_json sends JSON and parses response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b'{"status": "ok"}\n' + + response = client.send_json({"command": "test"}) + + assert response == {"status": "ok"} + # Verify JSON was sent + call_args = mock_socket.sendall.call_args[0][0] + sent_data = json.loads(call_args.decode().rstrip("\n")) + assert sent_data == {"command": "test"} + + def test_send_json_invalid_response(self, connected_client): + """send_json wraps invalid JSON response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b"NOT JSON\n" + + response = client.send_json({"command": "test"}) + + assert response == {"raw": "NOT JSON"} + + +class TestTCPRobotClientCommands: + """Tests for TCPRobotClient standard commands.""" + + @pytest.fixture + def connected_client(self): + """Create a connected client with mock socket.""" + client = TCPRobotClient("192.168.1.100", 10000) + mock_socket = MagicMock() + mock_socket.recv.return_value = b"OK\n" + + with patch("socket.socket", return_value=mock_socket): + client.connect() + + return client, mock_socket + + def test_home(self, connected_client): + """home() sends HOME command.""" + client, mock_socket = connected_client + + client.home() + + mock_socket.sendall.assert_called_with(b"HOME\n") + + def test_stop(self, connected_client): + """stop() sends STOP command.""" + client, mock_socket = connected_client + + client.stop() + + mock_socket.sendall.assert_called_with(b"STOP\n") + + def test_pause(self, connected_client): + """pause() sends PAUSE command.""" + client, mock_socket = connected_client + + client.pause() + + mock_socket.sendall.assert_called_with(b"PAUSE\n") + + def test_resume(self, connected_client): + """resume() sends RESUME command.""" + client, mock_socket = connected_client + + client.resume() + + mock_socket.sendall.assert_called_with(b"RESUME\n") + + def test_load_part(self, connected_client): + """load_part() sends LOAD_PART command.""" + client, mock_socket = connected_client + + client.load_part() + + mock_socket.sendall.assert_called_with(b"LOAD_PART\n") + + def test_load_part_with_id(self, connected_client): + """load_part() with part_id includes ID in command.""" + client, mock_socket = connected_client + + client.load_part("part_001") + + mock_socket.sendall.assert_called_with(b"LOAD_PART part_001\n") + + def test_unload_part(self, connected_client): + """unload_part() sends UNLOAD_PART command.""" + client, mock_socket = connected_client + + client.unload_part() + + mock_socket.sendall.assert_called_with(b"UNLOAD_PART\n") + + def test_unload_part_with_id(self, connected_client): + """unload_part() with part_id includes ID in command.""" + client, mock_socket = connected_client + + client.unload_part("part_001") + + mock_socket.sendall.assert_called_with(b"UNLOAD_PART part_001\n") + + def test_tool_change(self, connected_client): + """tool_change() sends TOOL_CHANGE command with tool number.""" + client, mock_socket = connected_client + + client.tool_change(5) + + mock_socket.sendall.assert_called_with(b"TOOL_CHANGE 5\n") + + def test_inspect(self, connected_client): + """inspect() sends INSPECT command.""" + client, mock_socket = connected_client + + client.inspect("dimensional") + + mock_socket.sendall.assert_called_with(b"INSPECT dimensional\n") + + def test_move_to(self, connected_client): + """move_to() sends MOVE command with coordinates.""" + client, mock_socket = connected_client + + client.move_to(10.5, 20.0, -5.0) + + mock_socket.sendall.assert_called_with(b"MOVE X10.5 Y20.0 Z-5.0\n") + + +class TestTCPRobotClientStatus: + """Tests for TCPRobotClient get_status().""" + + @pytest.fixture + def connected_client(self): + """Create a connected client with mock socket.""" + client = TCPRobotClient("192.168.1.100", 10000) + mock_socket = MagicMock() + + with patch("socket.socket", return_value=mock_socket): + client.connect() + + return client, mock_socket + + def test_get_status_json_response(self, connected_client): + """get_status parses JSON response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = ( + b'{"ready": true, "busy": false, "position": {"x": 1.0}}\n' + ) + + status = client.get_status() + + assert status.connected is True + assert status.ready is True + assert status.busy is False + assert status.position == {"x": 1.0} + + def test_get_status_text_response_ready(self, connected_client): + """get_status parses READY text response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b"READY\n" + + status = client.get_status() + + assert status.connected is True + assert status.ready is True + assert status.busy is False + + def test_get_status_text_response_busy(self, connected_client): + """get_status parses BUSY text response.""" + client, mock_socket = connected_client + mock_socket.recv.return_value = b"BUSY\n" + + status = client.get_status() + + assert status.connected is True + assert status.ready is False + assert status.busy is True + + def test_get_status_connection_error(self, connected_client): + """get_status returns error status on connection failure.""" + client, mock_socket = connected_client + mock_socket.recv.side_effect = TimeoutError("timeout") + + status = client.get_status() + + assert status.connected is False + assert "timeout" in status.error.lower() + + +class TestTCPRobotClientEventBinding: + """Tests for TCPRobotClient event binding.""" + + def test_bind_events(self): + """bind_events registers handlers.""" + from cnckit.core import Event, EventEmitter + + client = TCPRobotClient("192.168.1.100", 10000) + events = EventEmitter() + + client.bind_events(events) + + assert len(events.listeners(Event.JOB_STARTED)) > 0 + assert len(events.listeners(Event.JOB_COMPLETED)) > 0 + + def test_unbind_events(self): + """disconnect removes event handlers.""" + from cnckit.core import Event, EventEmitter + + client = TCPRobotClient("192.168.1.100", 10000) + events = EventEmitter() + + client.bind_events(events) + client.disconnect() + + assert len(events.listeners(Event.JOB_STARTED)) == 0 + assert len(events.listeners(Event.JOB_COMPLETED)) == 0 + + def test_auto_load_on_job_started(self): + """With auto_load, load_part is called on job started.""" + from cnckit.core import Event, EventEmitter, Job + + client = TCPRobotClient("192.168.1.100", 10000) + events = EventEmitter() + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"OK\n" + + with patch("socket.socket", return_value=mock_socket): + client.connect() + + client.bind_events(events, auto_load=True) + + job = Job(path=Path("/test.ngc")) + events.emit(Event.JOB_STARTED, job) + + # Should have called LOAD_PART with job path + mock_socket.sendall.assert_called_with(b"LOAD_PART /test.ngc\n") + + def test_auto_unload_on_job_completed(self): + """With auto_unload, unload_part is called on job completed.""" + from cnckit.core import Event, EventEmitter, Job + + client = TCPRobotClient("192.168.1.100", 10000) + events = EventEmitter() + + mock_socket = MagicMock() + mock_socket.recv.return_value = b"OK\n" + + with patch("socket.socket", return_value=mock_socket): + client.connect() + + client.bind_events(events, auto_unload=True) + + job = Job(path=Path("/test.ngc")) + events.emit(Event.JOB_COMPLETED, job) + + mock_socket.sendall.assert_called_with(b"UNLOAD_PART /test.ngc\n") + + def test_no_auto_load_if_not_connected(self): + """Auto load does nothing if not connected.""" + from cnckit.core import Event, EventEmitter, Job + + client = TCPRobotClient("192.168.1.100", 10000) + events = EventEmitter() + + client.bind_events(events, auto_load=True) + + job = Job(path=Path("/test.ngc")) + # Should not raise even though not connected + events.emit(Event.JOB_STARTED, job) + + +class TestROS2InterfaceImportError: + """Tests for ROS2Interface without rclpy.""" + + def test_import_raises_without_rclpy(self): + """ROS2Interface raises ImportError without rclpy.""" + # The ROS2Interface class checks on __init__, not import + from cnckit.integrations.robot import ROS2Interface + + with ( + patch("builtins.__import__", side_effect=ImportError("No module")), + pytest.raises(ImportError, match="rclpy is required"), + ): + ROS2Interface() + + +class TestROS2InterfaceWithMock: + """Tests for ROS2Interface with mocked rclpy.""" + + @pytest.fixture + def mock_rclpy(self): + """Create mock rclpy modules.""" + mock_node = MagicMock() + mock_publisher = MagicMock() + mock_node.create_publisher.return_value = mock_publisher + mock_node.create_subscription.return_value = MagicMock() + + mock_node_class = MagicMock(return_value=mock_node) + + mock_string = MagicMock() + + return { + "node": mock_node, + "node_class": mock_node_class, + "publisher": mock_publisher, + "string": mock_string, + } + + def test_init_creates_publishers(self, mock_rclpy): + """ROS2Interface creates publishers for each topic.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface(node_name="test_node") + + assert interface.node_name == "test_node" + # Should have created publishers + assert mock_rclpy["node"].create_publisher.call_count == 5 + + def test_publish_machine_state(self, mock_rclpy): + """publish_machine_state publishes to correct topic.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface() + interface.publish_machine_state("running", progress=0.5) + + # Verify publish was called + mock_rclpy["publisher"].publish.assert_called() + + def test_on_command_callback(self, mock_rclpy): + """on_command registers callback for commands.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface() + + callback = MagicMock() + interface.on_command(callback) + + # Simulate receiving a command + mock_msg = MagicMock() + mock_msg.data = "START" + interface._on_command(mock_msg) + + callback.assert_called_once_with("START") + + def test_bind_events(self, mock_rclpy): + """bind_events registers event handlers.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.core import Event, EventEmitter + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface() + events = EventEmitter() + + interface.bind_events(events) + + assert len(events.listeners(Event.JOB_STARTED)) > 0 + assert len(events.listeners(Event.JOB_COMPLETED)) > 0 + assert len(events.listeners(Event.JOB_FAILED)) > 0 + assert len(events.listeners(Event.QUEUE_EMPTY)) > 0 + + def test_unbind_events(self, mock_rclpy): + """unbind_events removes event handlers.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.core import Event, EventEmitter + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface() + events = EventEmitter() + + interface.bind_events(events) + interface.unbind_events() + + assert len(events.listeners(Event.JOB_STARTED)) == 0 + + def test_destroy(self, mock_rclpy): + """destroy() cleans up ROS2 resources.""" + with ( + patch.dict( + "sys.modules", + { + "rclpy": MagicMock(), + "rclpy.node": MagicMock(Node=mock_rclpy["node_class"]), + "std_msgs": MagicMock(), + "std_msgs.msg": MagicMock(String=mock_rclpy["string"]), + }, + ), + ): + from cnckit.integrations.robot import ROS2Interface + + interface = ROS2Interface() + interface.destroy() + + mock_rclpy["node"].destroy_node.assert_called_once() From 1b8127abd585c799f1544c0d85c3eb7a74e203c6 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:22:24 -0800 Subject: [PATCH 06/11] fix: add dashboard page and upate roadmap/toml --- docs/roadmap.md | 12 +- pyproject.toml | 10 +- .../integrations/api/static/dashboard.html | 517 ++++++++++++++++++ 3 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 src/cnckit/integrations/api/static/dashboard.html diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c4b2c7..8f17a32 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,16 +18,16 @@ This document outlines planned features and development phases. - [x] Event system for job lifecycle callbacks - [x] Configuration loader with defaults -## Optional Integrations (Phase 2) +## Optional Integrations (Phase 2) ✅ -- [ ] REST API for remote monitoring (FastAPI) -- [ ] MQTT client for messaging and automation -- [ ] WebSocket real-time status streaming -- [ ] Simple web dashboard for queue & machine state +- [x] REST API for remote monitoring (FastAPI) +- [x] MQTT client for messaging and automation +- [x] WebSocket real-time status streaming +- [x] Robot integration (ROS2, TCP commands) +- [x] Simple web dashboard for queue & machine state ## Future Expansion (Phase 3+) -- [ ] Robot integration (ROS2, TCP commands) - [ ] Plugin system for user-contributed modules - [ ] Industry protocols (OPC-UA, PLC handshakes) - [ ] Multi-machine coordination diff --git a/pyproject.toml b/pyproject.toml index 6614dbb..fa493ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "pytest>=8.0.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", "ruff>=0.4.0", "mypy>=1.10.0", "pre-commit>=3.7.0", @@ -133,7 +134,7 @@ known-first-party = ["cnckit"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["ARG001", "PLR2004", "ERA001", "PLC0415"] -"src/cnckit/integrations/**/*.py" = ["ARG002", "PLC0415", "B904"] +"src/cnckit/integrations/**/*.py" = ["ARG002", "PLC0415", "B904", "B008", "PLR0915"] # ============================================================================ # mypy configuration @@ -152,5 +153,10 @@ warn_redundant_casts = true warn_unused_ignores = true [[tool.mypy.overrides]] -module = ["linuxcnc.*", "paho.*", "rclpy.*", "fastapi.*", "websockets.*"] +module = ["linuxcnc.*", "paho.*", "rclpy.*", "fastapi.*", "websockets.*", "pydantic.*", "uvicorn.*", "starlette.*", "std_msgs.*"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["cnckit.integrations.*"] +disallow_untyped_decorators = false +disable_error_code = ["misc"] diff --git a/src/cnckit/integrations/api/static/dashboard.html b/src/cnckit/integrations/api/static/dashboard.html new file mode 100644 index 0000000..762d3ab --- /dev/null +++ b/src/cnckit/integrations/api/static/dashboard.html @@ -0,0 +1,517 @@ + + + + + + CNCKit Dashboard + + + +
+
+

CNCKit Dashboard

+
+ + Connecting... +
+
+ +
+ +
+
+ Machine Status + IDLE +
+
+
+
X
+
0.000
+
+
+
Y
+
0.000
+
+
+
Z
+
0.000
+
+
+
+
Current Program
+
None
+
+
+
Tool
+
T0
+
+
+
+
+
+ 0% complete +
+
+ + +
+
+ Scheduler + STOPPED +
+
+
Current Job
+
None
+
+
+ + + +
+
+ + +
+
+ Job Queue + 0 jobs +
+
+
No jobs in queue
+
+
+ + +
+
+ Event Log + +
+
+
Waiting for events...
+
+
+
+
+ + + + From 9dc9a272d3b05ba511456467183f05e0f119e8a6 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:26:15 -0800 Subject: [PATCH 07/11] fix: remove unused ignore comment and update paho-mqtt version dependency --- pyproject.toml | 2 +- src/cnckit/integrations/mqtt/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fa493ea..84d12b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,4 +159,4 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = ["cnckit.integrations.*"] disallow_untyped_decorators = false -disable_error_code = ["misc"] +disable_error_code = ["misc", "attr-defined"] diff --git a/src/cnckit/integrations/mqtt/__init__.py b/src/cnckit/integrations/mqtt/__init__.py index 567a236..38f7a0f 100644 --- a/src/cnckit/integrations/mqtt/__init__.py +++ b/src/cnckit/integrations/mqtt/__init__.py @@ -198,7 +198,7 @@ def __init__( # Use CallbackAPIVersion.VERSION2 for paho-mqtt 2.0+ try: self._client = mqtt.Client( - callback_api_version=mqtt.CallbackAPIVersion.VERSION2, # type: ignore[attr-defined] + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=client_id or "", ) except (AttributeError, TypeError): From 2af2bdaaa8d02ba920187757d5f80396354d2e9a Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:37:13 -0800 Subject: [PATCH 08/11] ci: add CodeRabbit AI review configuratio --- .coderabbit.yaml | 143 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..849264d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,143 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "en-US" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + review_status: true + commit_status: true + collapse_walkthrough: false + changed_files_summary: true + sequence_diagrams: true + poem: false + abort_on_close: true + + # Path-specific review instructions + path_instructions: + - path: "src/cnckit/core/**/*.py" + instructions: | + This is the core module - it must remain lean and dependency-free. + - No external dependencies allowed (only Python stdlib) + - All public APIs must have type hints + - Ensure backwards compatibility + - Check for thread safety in Machine and Scheduler classes + + - path: "src/cnckit/integrations/**/*.py" + instructions: | + These are optional integration modules with external dependencies. + - Each integration must validate its dependencies on import + - Must raise ImportError with install instructions if deps missing + - Should integrate with EventEmitter for event binding + - Check that optional deps are in pyproject.toml extras + + - path: "tests/**/*.py" + instructions: | + Test files should follow pytest conventions. + - Use fixtures appropriately + - Mock external services, don't make real connections + - Test both success and error paths + - Integration tests should be marked with @pytest.mark.integration + + - path: "docs/**/*.md" + instructions: | + Documentation uses MkDocs with Material theme. + - Code examples should be runnable + - API references use mkdocstrings format (:::) + - Keep examples simple and focused + + # Files to exclude from review + path_filters: + - "!**/*.lock" + - "!**/poetry.lock" + - "!**/.gitignore" + - "!**/uv.lock" + + auto_review: + enabled: true + auto_incremental_review: true + drafts: false + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "wip" + base_branches: + - "main" + - "develop" + + # Finishing touches - auto-suggest improvements + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + + # Pre-merge quality checks + pre_merge_checks: + title_check: + mode: "warning" + requirements: | + Follow conventional commits format: + - Start with type: feat, fix, docs, chore, refactor, test, ci + - Optional scope in parentheses: feat(core), fix(mqtt) + - Imperative mood: "add feature" not "added feature" + - Under 72 characters + + description_check: + mode: "warning" + + issue_assessment: + mode: "off" + + # Static analysis tools + tools: + ruff: + enabled: true + mypy: + enabled: true + markdownlint: + enabled: true + github-checks: + enabled: true + timeout_ms: 120000 + +chat: + auto_reply: true + +knowledge_base: + opt_out: false + learnings: + scope: "auto" + issues: + scope: "local" + pull_requests: + scope: "local" + +code_generation: + docstrings: + language: "en-US" + path_instructions: + - path: "src/cnckit/**/*.py" + instructions: | + Use Google-style docstrings. + Include Args, Returns, Raises sections as appropriate. + Keep descriptions concise but complete. + + unit_tests: + path_instructions: + - path: "src/cnckit/core/**/*.py" + instructions: | + Generate pytest-style tests. + Use fixtures from conftest.py when available. + Test edge cases and error conditions. + Mock time-dependent operations. + + - path: "src/cnckit/integrations/**/*.py" + instructions: | + Generate pytest-style tests. + Mock all external service connections. + Test import error when dependencies missing. + Test event binding functionality. From 864c4d199dedaa16454b3be72dd99f5311cfa110 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:50:47 -0800 Subject: [PATCH 09/11] fix(config): correct pre_merge_checks key names in coderabbit config Rename title_check to title and description_check to description to match the CodeRabbit schema v2 specification. --- .coderabbit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 849264d..a05ff01 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -77,7 +77,7 @@ reviews: # Pre-merge quality checks pre_merge_checks: - title_check: + title: mode: "warning" requirements: | Follow conventional commits format: @@ -86,7 +86,7 @@ reviews: - Imperative mood: "add feature" not "added feature" - Under 72 characters - description_check: + description: mode: "warning" issue_assessment: From 1547aa3beb17548c7a7ff6d3cc448efb2eb6f748 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 00:52:18 -0800 Subject: [PATCH 10/11] docs(api): add blank lines before fenced code blocks Fix MD031 markdown lint warnings by ensuring blank lines precede all fenced code blocks in the API documentation. --- docs/api/integrations/api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api/integrations/api.md b/docs/api/integrations/api.md index 9061b63..f12c581 100644 --- a/docs/api/integrations/api.md +++ b/docs/api/integrations/api.md @@ -76,6 +76,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run | GET | `/health` | Health check - returns service status and timestamp | **Response:** + ```json { "status": "healthy", @@ -90,6 +91,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run | GET | `/machine/status` | Get current machine state, position, and program info | **Response:** + ```json { "state": "idle", @@ -126,6 +128,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run | DELETE | `/queue/{job_id}` | Remove a job from the queue | **GET /queue Response:** + ```json { "mode": "fifo", @@ -148,6 +151,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run ``` **POST /queue Request:** + ```json { "path": "/path/to/part.ngc", @@ -157,6 +161,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run ``` **POST /queue Response (201 Created):** + ```json { "message": "Job added to queue", @@ -193,6 +198,7 @@ Access the dashboard at `http://localhost:8000/dashboard` when the server is run | POST | `/scheduler/tick` | Manually trigger a scheduler tick | **GET /scheduler/status Response:** + ```json { "state": "running", From 73664a7ec32ea6dd556822755c10852884b21086 Mon Sep 17 00:00:00 2001 From: jkkicks Date: Sat, 29 Nov 2025 16:31:10 -0800 Subject: [PATCH 11/11] fix(config): remove unsupported mypy from coderabbit tools CodeRabbit only supports ruff, markdownlint, and github-checks as built-in tools. mypy type checking is handled via CI pipeline. --- .coderabbit.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a05ff01..4dbf56b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -92,12 +92,10 @@ reviews: issue_assessment: mode: "off" - # Static analysis tools + # Static analysis tools (mypy runs via CI, not CodeRabbit) tools: ruff: enabled: true - mypy: - enabled: true markdownlint: enabled: true github-checks: