Compare commits

..

7 Commits

Author SHA1 Message Date
phundrak a965848076 docs: add community governance and contribution guidelines
- Add CONTRIBUTING.md with TDD requirements, PR workflow, and AI usage
  policy
- Add CODE_OF_CONDUCT.md based on Contributor Covenant
- Add SECURITY.md with vulnerability reporting scope and process
- Add AGENTS.md with AI usage policy for human contributors and AI
  agents
- Add CLAUDE.md to require reading AGENTS.md before any work
- Add Gitea issue templates for bug reports and feature requests
- Add pull request template with TDD and code quality checklist
2026-02-14 00:01:37 +01:00
phundrak 7ce35da1ce feat(application): implement US1 relay control use cases
Add GetAllRelaysUseCase (T043) for retrieving all 8 relay states with
labels, coordinating controller reads and repository label lookups
with comprehensive error handling and logging.

Implement ToggleRelayUseCase (T041) for toggling individual relay
states with read-before-write pattern, state validation, and label
retrieval.

Add use_cases module (T044) with trait-based dependency injection for
testability, exposing both use cases for presentation layer
integration.

Comprehensive test coverage includes 7 toggle tests (state
transitions, error handling, double-toggle idempotency) and 9 get-all
tests (count, ordering, state correctness, label inclusion, error
scenarios).

Ref: T041 T042 T043 T044 (specs/001-modbus-relay-control/tasks.org)
2026-01-23 20:53:48 +01:00
phundrak 27cfeb3b77 docs: update README for Phase 3 infrastructure completion
Update README to reflect completed Phase 3 infrastructure layer:
- Documented ModbusRelayController, MockRelayController, SqliteRelayLabelRepository, and HealthMonitor implementations
- Added testing coverage details (20+ tests across infrastructure components)
- Updated architecture diagrams and project structure
- Changed task reference to tasks.org format
- Updated dependency list with production infrastructure dependencies

Ref: Phase 3 tasks in specs/001-modbus-relay-control/tasks.org
2026-01-22 01:15:27 +01:00
phundrak f726f4185a feat(infrastructure): implement SQLite repository for relay labels
Add complete SQLite implementation of RelayLabelRepository trait with
all CRUD operations (get_label, save_label, delete_label, get_all_labels).

Key changes:
- Create infrastructure entities module with RelayLabelRecord struct
- Implement TryFrom traits for converting between database records and domain types
- Add From<sqlx::Error> and From<RelayLabelError> for RepositoryError
- Write comprehensive functional tests covering all repository operations
- Verify proper handling of edge cases (empty results, overwrites, max length)

TDD phase: GREEN - All repository trait tests now passing with SQLite implementation

Ref: T036 (specs/001-modbus-relay-control/tasks.md)
2026-01-22 00:57:11 +01:00
phundrak ce186095fa feat(application): HealthMonitor service and hardware integration test
Add HealthMonitor service for tracking system health status with
comprehensive state transition logic and thread-safe operations.
Includes 16 unit tests covering all functionality including concurrent
access scenarios.

Add optional Modbus hardware integration tests with 7 test cases for
real device testing. Tests are marked as ignored and can be run with

running 21 tests
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure ... FAILED
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false ... FAILED
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error ... FAILED
test infrastructure::modbus::client::tests::t025a_connection_setup_tests::test_new_with_valid_config_connects_successfully ... ok
test infrastructure::modbus::client::tests::t025a_connection_setup_tests::test_new_stores_correct_timeout_duration ... ok
test infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_coil_values_on_success ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_9_states_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_empty_vector_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_can_toggle_relay_multiple_times ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_8_states_succeeds ... ok
test infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_succeeds_for_valid_write ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil ... FAILED
test infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_correctly_maps_relay_id_to_modbus_address ... ok
test infrastructure::modbus::client::tests::write_all_states_validation_tests::test_write_all_states_with_7_states_returns_invalid_input ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_on_writes_true_to_coil ... ok
test infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_correctly_maps_relay_id_to_modbus_address ... ok

failures:

---- infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure stdout ----

thread 'infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure' (1157113) panicked at backend/src/infrastructure/modbus/client_test.rs:320:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device stdout ----

thread 'infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device' (1157114) panicked at backend/src/infrastructure/modbus/client_test.rs:293:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response' (1157112) panicked at backend/src/infrastructure/modbus/client_test.rs:176:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error' (1157111) panicked at backend/src/infrastructure/modbus/client_test.rs:227:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true' (1157119) panicked at backend/src/infrastructure/modbus/client_test.rs:354:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error' (1157117) panicked at backend/src/infrastructure/modbus/client_test.rs:396:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false stdout ----

thread 'infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false' (1157118) panicked at backend/src/infrastructure/modbus/client_test.rs:375:14:
Failed to connect to test server: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error stdout ----

thread 'infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error' (1157110) panicked at backend/src/infrastructure/modbus/client_test.rs:202:14:
Failed to connect: ConnectionError("Connection refused (os error 111)")

---- infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil stdout ----

thread 'infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil' (1157122) panicked at backend/src/infrastructure/modbus/client_test.rs:508:9:
assertion `left == right` failed: Relay should be Off after writing Off state
  left: On
 right: Off


failures:
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_connection_error_on_io_error
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_modbus_exception_on_protocol_error
    infrastructure::modbus::client::tests::t025b_read_coils_timeout_tests::test_read_coils_returns_timeout_on_slow_response
    infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_error_on_failure
    infrastructure::modbus::client::tests::t025c_write_single_coil_timeout_tests::test_write_single_coil_returns_timeout_on_slow_device
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_propagates_controller_error
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_off_when_coil_is_false
    infrastructure::modbus::client::tests::t025d_read_relay_state_tests::test_read_state_returns_on_when_coil_is_true
    infrastructure::modbus::client::tests::t025e_write_relay_state_tests::test_write_state_off_writes_false_to_coil

test result: FAILED. 12 passed; 9 failed; 0 ignored; 0 measured; 128 filtered out; finished in 3.27s.

Ref: T034, T039, T040 (specs/001-modbus-relay-control/tasks.org)
2026-01-22 00:57:11 +01:00
phundrak 1cb4d5f3fc refactor(specs): switch tasks to org format 2026-01-22 00:57:11 +01:00
phundrak 8c1d5433de test(infrastructure): write RelayLabelRepository trait tests
Add reusable test suite with 18 test functions covering get_label(),
save_label(), delete_label(), and get_all_labels() methods. Tests
verify contract compliance for any repository implementation.

Added delete_label() method to trait interface and implemented it in
MockRelayLabelRepository to support complete CRUD operations.

TDD phase: RED - Tests written before SQLite implementation (T036)

Ref: T035 (specs/001-modbus-relay-control/tasks.md)
2026-01-22 00:57:11 +01:00
81 changed files with 1809 additions and 5732 deletions
-18
View File
@@ -1,18 +0,0 @@
root = true
[*]
end_of_line = true
insert_final_newline = true
charset = utf-8
[*.{vue,js,ts,json}]
indent_style = space
indent_size = 2
[*.{rs,yaml}]
indent_style = space
indent_size = 4
[{justfile,*.just}]
indent_style = tab
indent_size = 4
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM RelayLabels ORDER BY relay_id",
"describe": {
"columns": [
{
"name": "relay_id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "label",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "117e7029e31f9283bbed6b5b3df23c4cdc025b9f7f14a392d63a99e8caef65cb"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM RelayLabels WHERE relay_id = ?1",
"describe": {
"columns": [
{
"name": "relay_id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "label",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "15738a0f943596d60a342c973435b94e1b7dc3199ad9fb400db6db349141b560"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO RelayLabels (relay_id, label) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "50a914fac9783ac8afb0305f6225680017d32a0dd95932ddb736d7df3ca31550"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM RelayLabels WHERE relay_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "720b84ab40bf4395727575b3cd6c25eff9198526705208ecdb25773f5cc80316"
}
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+1
View File
@@ -0,0 +1 @@
IMPORTANT: Ensure youve thoroughly reviewed the [AGENTS.md](/AGENTS.md) file before beginning any work.
+164 -146
View File
@@ -14,17 +14,11 @@
</div>
<br/>
> **🤖 AI-Assisted Development Notice**: This project uses AI as a development assistant for task planning, code organization, and workflow management. However, all code is human-written, reviewed, and validated by the project maintainer. AI is used as a productivity tool, not as the author of the implementation.
> **🤖 AI-Assisted Development Notice**: This project uses Claude Code as a development assistant for task planning, code organization, and workflow management. However, all code is human-written, reviewed, and validated by the project maintainer. AI is used as a productivity tool, not as the author of the implementation.
Web-based Modbus relay control system for managing 8-channel relay modules over TCP.
<div align="center">
<figure>
<img src="./assets/screenshot.png" alt="Screenshot of STA" width="800" />
<figcaption>Screenshot of STA</figcaption>
</figure>
</div>
> **⚠️ Development Status**: This project is in early development. Core features are currently being implemented following a specification-driven approach.
## Overview
@@ -32,14 +26,99 @@ STA will provide a modern web interface for controlling Modbus-compatible relay
## Current Status
**US1 (MVP) — Complete** — Users can view all 8 relay states and toggle individual relays on/off via the web UI, backed by a Rust API with Modbus TCP control. Phases 14 complete: domain layer with type-driven development, infrastructure with mock/real Modbus controllers and SQLite persistence, application use cases, REST API with OpenAPI docs, and Vue 3 frontend with real-time polling.
### Phase 1 Complete - Foundation
- ✅ Monorepo structure (backend + frontend at root)
- ✅ Rust web server with Poem 3.1 framework
- ✅ Configuration system (YAML + environment variables)
- ✅ Modbus TCP and relay settings structures
- ✅ Health check and metadata API endpoints
- ✅ OpenAPI documentation with Swagger UI
- ✅ Rate limiting middleware
- ✅ SQLite schema and repository for relay labels
- ✅ Vue 3 + TypeScript frontend scaffolding with Vite
- ✅ Type-safe API client generation from OpenAPI specs
### Phase 0.5 Complete - CORS Configuration & Production Security
- ✅ T009: CorsSettings struct with comprehensive unit tests (5 tests)
- ✅ T010: CorsSettings implementation with restrictive fail-safe defaults
- ✅ T011: Development YAML configuration with permissive CORS
- ✅ T012: Production YAML configuration with restrictive CORS
- ✅ T013: From<CorsSettings> for Cors trait unit tests (6 tests)
- ✅ T014: From<CorsSettings> for Cors implementation with security validation
- ✅ T015: Middleware chain integration using From trait
- ✅ T016: Integration tests for CORS headers (9 comprehensive tests)
#### Key CORS Features Implemented
- Environment-specific CORS configuration (development vs production)
- Wildcard origin support for development (`allowed_origins: ["*"]`)
- Multiple specific origins for production
- Credentials support for Authelia authentication
- Security validation (prevents wildcard + credentials)
- Configurable preflight cache duration
- Hardcoded secure methods and headers
- Structured logging for CORS configuration
- Comprehensive test coverage (15 tests total)
### Phase 2 Complete - Domain Layer (Type-Driven Development)
- ✅ T017-T018: RelayId newtype with 1-8 validation and zero-cost abstraction
- ✅ T019-T020: RelayState enum (On/Off) with serialization support
- ✅ T021-T022: Relay aggregate with state control methods (toggle, turn_on, turn_off)
- ✅ T023-T024: RelayLabel newtype with 1-50 character validation
- ✅ T025-T026: ModbusAddress type with From<RelayId> trait (1-8 → 0-7 offset mapping)
- ✅ T027: HealthStatus enum with state machine (Healthy/Degraded/Unhealthy)
#### Key Domain Layer Features Implemented
- 100% test coverage for domain layer (50+ comprehensive tests)
- Zero external dependencies (pure business logic)
- All newtypes use `#[repr(transparent)]` for zero-cost abstractions
- Smart constructors with `Result<T, E>` for type-safe validation
- TDD workflow (red-green-refactor) for all implementations
- RelayController and RelayLabelRepository trait definitions
- Complete separation from infrastructure concerns (hexagonal architecture)
### Phase 3 Complete - Infrastructure Layer
- ✅ T028-T029: MockRelayController tests and implementation
- ✅ T030: RelayController trait with async methods (read_state, write_state, read_all, write_all)
- ✅ T031: ControllerError enum (ConnectionError, Timeout, ModbusException, InvalidRelayId)
- ✅ T032: MockRelayController comprehensive tests (6 tests)
- ✅ T025a-f: ModbusRelayController implementation (decomposed):
- Connection setup with tokio-modbus
- Timeout-wrapped read_coils and write_single_coil helpers
- RelayController trait implementation
- ✅ T034: Integration test with real hardware (uses #[ignore] attribute)
- ✅ T035-T036: RelayLabelRepository trait and SQLite implementation
- ✅ T037-T038: MockRelayLabelRepository for testing
- ✅ T039-T040: HealthMonitor service with state tracking
#### Key Infrastructure Features Implemented
- **ModbusRelayController**: Thread-safe Modbus TCP client with timeout handling
- Uses `Arc<Mutex<Context>>` for concurrent access
- Native Modbus TCP protocol (MBAP header, no CRC16)
- Configurable timeout with `tokio::time::timeout`
- **MockRelayController**: In-memory testing without hardware
- Uses `Arc<Mutex<HashMap<RelayId, RelayState>>>` for state
- Optional timeout simulation for error handling tests
- **SqliteRelayLabelRepository**: Compile-time verified SQL queries
- Automatic migrations via SQLx
- In-memory mode for testing
- **HealthMonitor**: State machine for health tracking
- Healthy -> Degraded -> Unhealthy transitions
- Recovery on successful operations
### Planned - Phases 4-8
- 📋 US1: Monitor & toggle relay states - MVP (Phase 4)
- 📋 US2: Bulk relay controls (Phase 5)
- 📋 US3: Health status display (Phase 6)
- 📋 US4: Relay labeling (Phase 7)
- 📋 Production deployment (Phase 8)
See [tasks.org](specs/001-modbus-relay-control/tasks.org) for detailed implementation roadmap.
## Architecture
**Current:**
- **Backend**: Rust 2024 with Poem web framework (hexagonal architecture)
- **Frontend**: Vue 3 + TypeScript with real-time polling (2s interval)
- **Configuration**: YAML-based with environment variable overrides
- **API**: RESTful HTTP with OpenAPI documentation
- **CORS**: Production-ready configurable middleware with security validation
- **Middleware Chain**: Rate Limiting -> CORS -> Data injection
@@ -47,6 +126,7 @@ STA will provide a modern web interface for controlling Modbus-compatible relay
- **Persistence**: SQLite for relay labels with compile-time SQL verification
**Planned:**
- **Frontend**: Vue 3 with TypeScript
- **Deployment**: Backend on Raspberry Pi, frontend on Cloudflare Pages
- **Access**: Traefik reverse proxy with Authelia authentication
@@ -60,20 +140,17 @@ STA will provide a modern web interface for controlling Modbus-compatible relay
### Development
```bash
# Run backend development server
just backend run
# Run development server
just run
# Run frontend
just frontend run
# Run tests
just test
# Run backend tests
just backend test
# Run linter
just lint
# Run backend linter
just backend lint
# Format backend code
just backend format
# Format code
just format
# Watch mode with bacon
bacon # clippy-all (default)
@@ -86,9 +163,9 @@ Edit `backend/settings/base.yaml` for Modbus device settings:
```yaml
modbus:
host: "192.168.1.200"
host: "192.168.0.200"
port: 502
slave_id: 1
slave_id: 0
timeout_secs: 5
relay:
@@ -107,7 +184,8 @@ APP__MODBUS__HOST=192.168.1.100 cargo run
```yaml
# backend/settings/development.yaml
cors:
allowed_origins: ["*"] # Permissive for local development
allowed_origins:
- "*" # Permissive for local development
allow_credentials: false # MUST be false with wildcard
max_age_secs: 3600
```
@@ -147,12 +225,12 @@ The server provides OpenAPI documentation via Swagger UI:
- OpenAPI Spec: `http://localhost:3100/specs`
**Current Endpoints:**
- `GET /api/relays` - List all relay states
- `POST /api/relays/{id}/toggle` - Toggle individual relay state
- `GET /api/health` - Health check endpoint
- `GET /api/meta` - Application metadata
**Planned Endpoints (see spec):**
- `GET /api/relays` - List all relay states
- `POST /api/relays/{id}/toggle` - Toggle relay state
- `POST /api/relays/all/on` - Turn all relays on
- `POST /api/relays/all/off` - Turn all relays off
- `PUT /api/relays/{id}/label` - Set relay label
@@ -161,133 +239,74 @@ The server provides OpenAPI documentation via Swagger UI:
**Monorepo Layout:**
```
sta/
├── backend/ # Rust backend
sta/ # Repository root
├── backend/ # Rust backend workspace member
│ ├── src/
│ │ ├── main.rs - Binary entry point
│ │ ├── lib.rs - Library entry point
│ │ ├── startup.rs - Application builder and server wiring
│ │ ├── main.rs - Binary entry point
│ │ ├── startup.rs - Application builder and server config
│ │ ├── telemetry.rs - Logging and tracing setup
│ │ │
│ │ ├── domain/ - Business logic
│ │ │ ├── health.rs - HealthStatus state machine
│ │ │ ├── modbus.rs - ModbusAddress type
│ │ │ └── relay/
│ │ │ ├── entity.rs - Relay aggregate (state control)
│ │ │ ── controller.rs - RelayController trait
│ │ │ ├── types/
│ │ │ │ ├── relayid.rs - RelayId newtype (1..=8)
│ │ │ │ ├── relaylabel.rs - RelayLabel newtype
│ │ │ │ └── relaystate.rs - RelayState enum (On/Off)
│ │ │ └── repository/
│ │ │ └── label.rs - RelayLabelRepository trait
│ │ ├── domain/ - Business logic layer (Phase 2)
│ │ │ ├── relay/ - Relay domain aggregate
│ │ │ │ ├── types/ - RelayId, RelayState, RelayLabel newtypes
│ │ │ │ ├── entity.rs - Relay aggregate with state control
│ │ │ ├── controller.rs - RelayController trait & ControllerError
│ │ │ ── repository/ - RelayLabelRepository trait
│ │ │ ├── modbus.rs - ModbusAddress type with conversion
│ │ │ └── health.rs - HealthStatus state machine
│ │ │
│ │ ├── application/ - Use cases
│ │ │ ── health/
│ │ │ └── health_monitor.rs - Health monitoring
│ │ │ └── use_cases/
│ │ │ ├── get_all_relays.rs - List all relays
│ │ │ └── toggle_relay.rs - Toggle single relay
│ │ ├── application/ - Use cases and orchestration (Phase 3)
│ │ │ ── health/ - Health monitoring service
│ │ │ └── health_monitor.rs - HealthMonitor with state tracking
│ │ │
│ │ ├── infrastructure/ - External integrations
│ │ │ ├── modbus/
│ │ │ │ ├── client.rs - ModbusRelayController
│ │ │ │ ├── client_test.rs - Unit tests
│ │ │ │ ── factory.rs - Controller factory (retry, fallback)
│ │ │ │ └── mock_controller.rs - MockRelayController
│ │ │ └── persistence/
│ │ │ ├── factory.rs - Repository factory
│ │ │ ── label_repository.rs - SQL implementation
│ │ │ ├── label_repository_tests.rs - Unit tests
│ │ │ ├── sqlite_repository.rs - SQLite implementation
│ │ │ └── entities/
│ │ │ └── relay_label_record.rs - DB row struct
│ │ ├── infrastructure/ - External integrations (Phase 3)
│ │ │ ├── modbus/ - Modbus TCP communication
│ │ │ │ ├── client.rs - ModbusRelayController (real hardware)
│ │ │ │ ├── client_test.rs - Hardware integration tests
│ │ │ │ ── mock_controller.rs - MockRelayController for testing
│ │ │ └── persistence/ - Database layer
│ │ │ ├── entities/ - Database record types
│ │ │ ├── sqlite_repository.rs - SqliteRelayLabelRepository
│ │ │ ── label_repository.rs - MockRelayLabelRepository
│ │ │
│ │ ├── presentation/ - API handlers and DTOs
│ │ │ ├── error.rs - API error types
│ │ │ ├── api/
│ │ │ │ └── relay_api.rs - Relay HTTP handlers
│ │ │ └── dto/
│ │ │ └── relay_dto.rs - Relay DTOs
│ │ │
│ │ ── route/ - Route definitions
│ │ ── health.rs - Health check
│ │ │ └── meta.rs - App metadata
│ │ │
│ │ ├── middleware/
│ │ │ └── rate_limit.rs - Rate limiting
│ │ │
│ │ └── settings/ - Configuration
│ │ ├── application.rs - App-wide settings
│ │ ├── cors.rs - CORS settings
│ │ ├── database.rs - Database settings
│ │ ├── environment.rs - Environment enum
│ │ ├── modbus.rs - Modbus settings
│ │ ├── rate_limiting.rs - Rate limit config
│ │ └── relay.rs - Relay settings
│ │ ├── presentation/ - API layer (planned Phase 4)
│ │ ├── settings/ - Configuration module
│ │ │ ├── mod.rs - Settings aggregation
│ │ │ └── cors.rs - CORS configuration
│ │ ├── route/ - HTTP endpoint handlers
│ │ │ ├── health.rs - Health check endpoints
│ │ │ └── meta.rs - Application metadata
│ │ ── middleware/ - Custom middleware
│ │ ── rate_limit.rs
│ │
│ ├── settings/ - YAML config files
│ │ ├── base.yaml
│ │ ├── development.yaml
│ │ └── production.yaml
└── tests/ - Integration/contract tests
│ ├── contract/
│ │ └── test_relay_api.rs - Relay API contract tests
│ ├── cors_test.rs - CORS integration tests
│ ├── modbus_hardware_test.rs - Hardware tests (#[ignore])
│ ├── sqlite_repository_test.rs - SQLite integration tests
│ └── sqlite_repository_functional_test.rs - Functional tests
│ ├── settings/ - YAML configuration files
│ │ ├── base.yaml - Base configuration
│ │ ├── development.yaml - Development overrides
│ │ └── production.yaml - Production overrides
└── tests/ - Integration tests
└── cors_test.rs - CORS integration tests
├── src/ # Frontend (Vue 3 + TypeScript)
│ ├── main.ts - App entry point
── App.vue - Root component
│ ├── style.css / style.less - Global styles
│ ├── api/
│ ├── client.ts - HTTP client
│ └── schema.ts - API types
│ ├── components/
│ ├── RelayCard.vue - Relay card
│ │ ├── StaFooter.vue - Footer
│ │ └── StaHeader.vue - Header
│ ├── composables/
│ │ ├── useMeta.ts - Page metadata
│ │ ├── useRelay.ts - Relay state management
│ │ └── useRelayPolling.ts - Real-time polling (2s)
│ ├── pages/
│ │ └── RelaysView.vue - Main relay view
│ ├── types/
│ │ ├── relay.ts - Relay type definitions
│ │ └── mappers/
│ │ └── relayDtoMapper.ts
│ └── utils/
│ └── isNil.ts
├── migrations/ - SQLx migrations
│ ├── 0001_relay-labels.up.sql
│ └── 0001_relay-labels.down.sql
├── docs/ - Documentation
│ ├── cors-configuration.md
│ ├── domain-layer.md
│ └── Modbus_POE_ETH_Relay.md
├── specs/ - Specifications
│ ├── constitution.md
├── migrations/ - SQLx database migrations
├── src/ # Frontend source (Vue/TypeScript)
── api/ - Type-safe API client
├── docs/ # Project documentation
│ ├── cors-configuration.md - CORS setup guide
│ ├── domain-layer.md - Domain layer architecture
│ └── Modbus_POE_ETH_Relay.md - Hardware documentation
├── specs/ # Feature specifications
│ ├── constitution.md - Architectural principles
│ └── 001-modbus-relay-control/
│ ├── spec.md, plan.md, tasks.org
│ ├── data-model.md, types-design.md
│ ├── domain-layer-architecture.md
│ ├── lessons-learned.md
── ...
├── nix/ - Nix flake configs
├── public/ - Static assets
├── justfile - Build commands
── package.json
├── vite.config.ts
├── Cargo.toml
└── flake.nix
│ ├── spec.md - Feature specification
│ ├── plan.md - Implementation plan
│ ├── tasks.org - Task breakdown (org-mode format)
│ ├── data-model.md - Data model specification
── types-design.md - Domain types design
├── domain-layer-architecture.md - Domain layer docs
│ └── lessons-learned.md - Phase 2/3 insights
├── package.json - Frontend dependencies
├── vite.config.ts - Vite build configuration
── justfile - Build commands
```
## Technology Stack
@@ -305,10 +324,9 @@ sta/
- thiserror (error handling)
- serde + serde_yaml (configuration deserialization)
**Frontend** (US1 complete):
- Vue 3 + TypeScript with composables (useRelayPolling)
**Frontend** (scaffolding complete):
- Vue 3 + TypeScript
- Vite build tool
- RelayCard and RelayGrid components with real-time polling
- openapi-typescript (type-safe API client generation)
## Testing Strategy
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

-6
View File
@@ -5,8 +5,6 @@ edition = "2024"
publish = false
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
license = "AGPL-3.0-only"
description = "Backend for STA, communicating with the physical relay"
homepage = "https://labs.phundrak.com/phundrak/sta"
[lib]
path = "src/lib.rs"
@@ -37,9 +35,5 @@ tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter
[dev-dependencies]
tempfile = "3.15.0"
[[test]]
name = "relay_api_contract"
path = "tests/contract/test_relay_api.rs"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
-52
View File
@@ -1,52 +0,0 @@
default: run
run:
cargo run
run-release:
cargo run --release
format:
cargo fmt --all
format-check:
cargo fmt --check --all
audit:
cargo deny check
build:
cargo build
build-release:
cargo build --release
lint:
cargo clippy --all-targets
release-build:
cargo build --release
release-run:
cargo run --release
[env("SQLX_OFFLINE", "1")]
test:
cargo test --all --all-targets
test-hardware:
cargo test --all --all-targets -- --ignored
coverage:
mkdir -p coverage
cargo tarpaulin --config .tarpaulin.local.toml
coverage-ci:
mkdir -p coverage
cargo tarpaulin --config .tarpaulin.ci.toml
check-all: format-check lint coverage audit
## Local Variables:
## mode: makefile
## End:
+5 -5
View File
@@ -3,14 +3,14 @@ application:
version: "0.1.0"
rate_limit:
enabled: false
burst_size: 100
per_seconds: 10
enabled: true
burst_size: 10
per_seconds: 60
modbus:
host: 192.168.1.200
host: 192.168.0.200
port: 502
slave_id: 1
slave_id: 0
timeout_secs: 5
relay:
@@ -265,15 +265,9 @@ mod tests {
for (index, relay) in result.iter().enumerate() {
let relay_num = index + 1;
if relay_num % 2 == 1 {
assert!(
relay.label().is_some(),
"Relay {relay_num} should have label"
);
assert!(relay.label().is_some(), "Relay {relay_num} should have label");
} else {
assert!(
relay.label().is_none(),
"Relay {relay_num} should not have label"
);
assert!(relay.label().is_none(), "Relay {relay_num} should not have label");
}
}
}
-404
View File
@@ -3,8 +3,6 @@
//! This module contains the core domain logic for relay control and management,
//! including relay types, repository abstractions, and business rules.
use types::{RelayId, RelayLabel, RelayState};
/// Controller error types for relay operations.
pub mod controller;
/// Relay entity representing the relay aggregate.
@@ -13,405 +11,3 @@ pub mod entity;
pub mod repository;
/// Domain types for relay identification and control.
pub mod types;
#[derive(Debug, Clone, PartialEq, Eq)]
/// A relay entity representing a physical relay device.
///
/// This struct encapsulates the core properties of a relay including its
/// unique identifier, current state (on/off), and an optional label for
/// user-friendly identification.
pub struct Relay {
id: RelayId,
state: RelayState,
label: RelayLabel,
}
impl Relay {
/// Creates a new relay with the specified ID.
///
/// The relay is initialized with the default state (Off) and default label.
///
/// # Arguments
///
/// * `id` - The unique identifier for the relay
///
/// # Returns
///
/// A new Relay instance with the given ID, Off state, and default label
#[must_use]
pub fn new(id: RelayId) -> Self {
Self::with_state(id, RelayState::Off)
}
/// Creates a new relay with the specified ID and state.
///
/// The relay is initialized with the given state and default label.
///
/// # Arguments
///
/// * `id` - The unique identifier for the relay
/// * `state` - The initial state of the relay (On or Off)
///
/// # Returns
///
/// A new Relay instance with the given ID, state, and default label
#[must_use]
pub fn with_state(id: RelayId, state: RelayState) -> Self {
Self::with_label(id, state, RelayLabel::default())
}
/// Creates a new relay with the specified ID, state, and label.
///
/// This is the most comprehensive constructor that allows full customization
/// of all relay properties.
///
/// # Arguments
///
/// * `id` - The unique identifier for the relay
/// * `state` - The initial state of the relay (On or Off)
/// * `label` - The user-friendly label for the relay
///
/// # Returns
///
/// A new Relay instance with the specified properties
#[must_use]
pub const fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self {
Self { id, state, label }
}
/// Returns the relay's unique identifier.
///
/// # Returns
///
/// The `RelayId` associated with this relay
#[must_use]
pub const fn id(&self) -> RelayId {
self.id
}
/// Returns the current state of the relay.
///
/// # Returns
///
/// The `RelayState` (On or Off) of this relay
#[must_use]
pub const fn state(&self) -> RelayState {
self.state
}
/// Returns a reference to the relay's label.
///
/// # Returns
///
/// A reference to the `RelayLabel` associated with this relay
#[must_use]
pub const fn label(&self) -> &RelayLabel {
&self.label
}
/// Toggles the relay's state between On and Off.
///
/// If the relay is currently On, it will be turned Off, and vice versa.
/// This operation preserves the relay's ID and label.
pub const fn toggle(&mut self) {
self.state = self.state.toggle();
}
/// Sets the relay's state to the specified value.
///
/// # Arguments
///
/// * `state` - The new state to set (On or Off)
///
/// This operation preserves the relay's ID and label.
pub const fn set_state(&mut self, state: RelayState) {
self.state = state;
}
/// Sets the relay's label to the specified value.
///
/// # Arguments
///
/// * `label` - The new label to assign to the relay
///
/// This operation preserves the relay's ID and state.
pub fn set_label(&mut self, label: RelayLabel) {
self.label = label;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_new_creates_relay_with_off_state() {
let relay_id = RelayId::new(1).unwrap();
let relay = Relay::new(relay_id);
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn test_relay_new_uses_default_label() {
let relay_id = RelayId::new(1).unwrap();
let relay = Relay::new(relay_id);
assert_eq!(relay.label(), &RelayLabel::default());
assert_eq!(relay.label().as_str(), "Unlabeled");
}
#[test]
fn test_relay_with_state_creates_relay_with_specified_state() {
let relay_id = RelayId::new(3).unwrap();
let relay = Relay::with_state(relay_id, RelayState::On);
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.state(), RelayState::On);
}
#[test]
fn test_relay_with_state_uses_default_label() {
let relay_id = RelayId::new(3).unwrap();
let relay = Relay::with_state(relay_id, RelayState::On);
assert_eq!(relay.label(), &RelayLabel::default());
}
#[test]
fn test_relay_with_label_creates_relay_with_all_fields() {
let relay_id = RelayId::new(5).unwrap();
let label = RelayLabel::new("Water Pump".to_string()).unwrap();
let relay = Relay::with_label(relay_id, RelayState::On, label.clone());
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.state(), RelayState::On);
assert_eq!(relay.label(), &label);
}
#[test]
fn test_relay_constructors_chain_correctly() {
let relay_id = RelayId::new(2).unwrap();
let relay1 = Relay::new(relay_id);
let relay2 = Relay::with_state(relay_id, RelayState::Off);
assert_eq!(relay1.id(), relay2.id());
assert_eq!(relay1.state(), relay2.state());
assert_eq!(relay1.label(), relay2.label());
}
#[test]
fn test_relay_id_returns_correct_id() {
for id_val in 1..=8 {
let relay_id = RelayId::new(id_val).unwrap();
let relay = Relay::new(relay_id);
assert_eq!(relay.id(), relay_id);
}
}
#[test]
fn test_relay_state_returns_correct_state() {
let relay_id = RelayId::new(1).unwrap();
let relay_on = Relay::with_state(relay_id, RelayState::On);
assert_eq!(relay_on.state(), RelayState::On);
let relay_off = Relay::with_state(relay_id, RelayState::Off);
assert_eq!(relay_off.state(), RelayState::Off);
}
#[test]
fn test_relay_label_returns_reference_to_label() {
let relay_id = RelayId::new(1).unwrap();
let label = RelayLabel::new("Test Label".to_string()).unwrap();
let relay = Relay::with_label(relay_id, RelayState::Off, label.clone());
assert_eq!(relay.label(), &label);
assert_eq!(relay.label().as_str(), "Test Label");
}
#[test]
fn test_relay_toggle_off_to_on() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::Off);
relay.toggle();
assert_eq!(relay.state(), RelayState::On);
}
#[test]
fn test_relay_toggle_on_to_off() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::On);
relay.toggle();
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn test_relay_toggle_idempotency() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::Off);
relay.toggle();
relay.toggle();
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn test_relay_toggle_preserves_id_and_label() {
let relay_id = RelayId::new(4).unwrap();
let label = RelayLabel::new("Light Switch".to_string()).unwrap();
let mut relay = Relay::with_label(relay_id, RelayState::Off, label.clone());
relay.toggle();
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.label(), &label);
}
#[test]
fn test_relay_set_state_to_on() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::Off);
relay.set_state(RelayState::On);
assert_eq!(relay.state(), RelayState::On);
}
#[test]
fn test_relay_set_state_to_off() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::On);
relay.set_state(RelayState::Off);
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn test_relay_set_state_same_state_is_idempotent() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::On);
relay.set_state(RelayState::On);
assert_eq!(relay.state(), RelayState::On);
}
#[test]
fn test_relay_set_state_preserves_id_and_label() {
let relay_id = RelayId::new(7).unwrap();
let label = RelayLabel::new("Heater".to_string()).unwrap();
let mut relay = Relay::with_label(relay_id, RelayState::Off, label.clone());
relay.set_state(RelayState::On);
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.label(), &label);
}
#[test]
fn test_relay_set_label_changes_label() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::new(relay_id);
let new_label = RelayLabel::new("New Label".to_string()).unwrap();
relay.set_label(new_label.clone());
assert_eq!(relay.label(), &new_label);
}
#[test]
fn test_relay_set_label_replaces_existing_label() {
let relay_id = RelayId::new(1).unwrap();
let initial_label = RelayLabel::new("Initial".to_string()).unwrap();
let mut relay = Relay::with_label(relay_id, RelayState::Off, initial_label);
let new_label = RelayLabel::new("Replaced".to_string()).unwrap();
relay.set_label(new_label.clone());
assert_eq!(relay.label(), &new_label);
assert_eq!(relay.label().as_str(), "Replaced");
}
#[test]
fn test_relay_set_label_preserves_id_and_state() {
let relay_id = RelayId::new(6).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::On);
let new_label = RelayLabel::new("Fan".to_string()).unwrap();
relay.set_label(new_label);
assert_eq!(relay.id(), relay_id);
assert_eq!(relay.state(), RelayState::On);
}
#[test]
fn test_relay_set_label_can_use_max_length_label() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::new(relay_id);
let max_label = RelayLabel::new("A".repeat(50)).unwrap();
relay.set_label(max_label.clone());
assert_eq!(relay.label(), &max_label);
assert_eq!(relay.label().as_str().len(), 50);
}
#[test]
fn test_relay_works_with_all_valid_ids() {
for id_val in 1..=8 {
let relay_id = RelayId::new(id_val).unwrap();
let relay = Relay::new(relay_id);
assert_eq!(relay.id().as_u8(), id_val);
assert_eq!(relay.state(), RelayState::Off);
}
}
#[test]
fn test_relay_multiple_state_changes() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::new(relay_id);
assert_eq!(relay.state(), RelayState::Off);
relay.toggle();
assert_eq!(relay.state(), RelayState::On);
relay.set_state(RelayState::Off);
assert_eq!(relay.state(), RelayState::Off);
relay.toggle();
assert_eq!(relay.state(), RelayState::On);
relay.set_state(RelayState::On);
assert_eq!(relay.state(), RelayState::On);
relay.toggle();
assert_eq!(relay.state(), RelayState::Off);
}
#[test]
fn test_relay_multiple_label_changes() {
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::new(relay_id);
assert_eq!(relay.label().as_str(), "Unlabeled");
relay.set_label(RelayLabel::new("Pump".to_string()).unwrap());
assert_eq!(relay.label().as_str(), "Pump");
relay.set_label(RelayLabel::new("Water Heater".to_string()).unwrap());
assert_eq!(relay.label().as_str(), "Water Heater");
relay.set_label(RelayLabel::default());
assert_eq!(relay.label().as_str(), "Unlabeled");
}
}
@@ -36,15 +36,6 @@ impl From<bool> for RelayState {
}
}
impl std::fmt::Display for RelayState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::On => write!(f, "on"),
Self::Off => write!(f, "off"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+3 -7
View File
@@ -44,23 +44,19 @@ impl ModbusRelayController {
/// - The host/port address is invalid
/// - Connection to the Modbus device fails
/// - The device is unreachable
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u8) -> Result<Self> {
pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) -> Result<Self> {
if slave_id != 1 {
tracing::warn!("Device typically uses slave_id=1, got {slave_id}");
}
let socket_addr = format!("{host}:{port}")
.parse()
.map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {e}")))?;
let ctx = timeout(
Duration::from_secs(timeout_secs.into()),
tcp::connect_slave(socket_addr, Slave(slave_id)),
)
let ctx = tcp::connect_slave(socket_addr, Slave(slave_id))
.await
.map_err(|_| ControllerError::Timeout(timeout_secs.into()))?
.map_err(|e| ControllerError::ConnectionError(e.to_string()))?;
Ok(Self {
ctx: Arc::new(Mutex::new(ctx)),
timeout_duration: Duration::from_secs(timeout_secs.into()),
timeout_duration: Duration::from_secs(timeout_secs),
})
}
@@ -1,176 +0,0 @@
//! Factory module for creating relay controller instances.
//!
//! This module provides factory functions for creating relay controllers
//! with graceful degradation and retry logic.
use std::sync::Arc;
use std::time::Duration;
use crate::domain::relay::controller::RelayController;
use crate::settings::ModbusSettings;
use super::client::ModbusRelayController;
use super::mock_controller::MockRelayController;
/// Creates a relay controller with retry and fallback logic.
///
/// # Parameters
///
/// - `settings`: Modbus connection configuration
/// - `use_mock`: If true, returns `MockRelayController` immediately without attempting real connection
///
/// # Behavior
///
/// 1. If `use_mock` is true, returns `MockRelayController` immediately
/// 2. Otherwise, attempts to connect to real Modbus hardware with:
/// - 3 retry attempts
/// - 2 second backoff between retries
/// 3. If all retries fail, falls back to `MockRelayController` (graceful degradation per FR-023)
///
/// # Returns
///
/// An `Arc<dyn RelayController>` that can be either:
/// - `MockRelayController` (for testing or when hardware connection fails)
/// - `ModbusRelayController` (for real hardware communication)
pub async fn create_relay_controller(
settings: &ModbusSettings,
use_mock: bool,
) -> Arc<dyn RelayController> {
if use_mock {
tracing::info!("Using MockRelayController (test mode)");
return Arc::new(MockRelayController::new());
}
for attempt in 1..=3 {
match ModbusRelayController::new(
&settings.host,
settings.port,
settings.slave_id,
settings.timeout_secs,
)
.await
{
Ok(controller) => {
tracing::info!("Connected to Modbus device on attempt {}", attempt);
return Arc::new(controller);
}
Err(e) => {
tracing::warn!(attempt, error = %e, "Failed to connect to Modbus device");
if attempt < 3 {
tracing::warn!("Retrying in two seconds...");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
tracing::error!("Could not connect to Modbus device after three attempts");
tracing::error!("Using MockRelayController as fallback");
tracing::error!("STA will NOT be controlling a real device!");
Arc::new(MockRelayController::new())
}
#[cfg(test)]
mod tests {
use crate::domain::relay::types::RelayId;
use super::*;
use std::time::Duration;
// Helper to create test settings
fn create_test_settings() -> ModbusSettings {
ModbusSettings {
host: "192.168.0.200".to_string(),
port: 502,
slave_id: 0,
timeout_secs: 5,
}
}
// T039a: Test 1 - use_mock=true returns MockRelayController immediately
#[tokio::test]
async fn test_create_relay_controller_with_mock_flag_returns_mock_immediately() {
// GIVEN: Settings and use_mock=true
let settings = create_test_settings();
// WHEN: create_relay_controller is called with use_mock=true
let start = std::time::Instant::now();
let controller = create_relay_controller(&settings, true).await;
let elapsed = start.elapsed();
// THEN: Should return MockRelayController immediately (< 100ms)
assert!(
elapsed < Duration::from_millis(100),
"Mock controller should be created immediately without delay, took {elapsed:?}"
);
// Verify it's a mock by checking if we can downcast to MockRelayController
// This is a weak test - in reality we'd check the type more carefully
// For now we just verify we got a controller back
assert!(Arc::strong_count(&controller) > 0);
}
// T039a: Test 2 - Successful connection returns ModbusRelayController
#[tokio::test]
#[ignore = "Requires real Modbus hardware"]
async fn test_create_relay_controller_successful_connection() {
// GIVEN: Valid settings for a real Modbus device
let settings = create_test_settings();
// WHEN: create_relay_controller is called with use_mock=false
let controller = create_relay_controller(&settings, false).await;
// THEN: Should return ModbusRelayController
// We verify by attempting a real operation
// Note: This test requires actual hardware and should be #[ignore]
let relay_id = RelayId::new(1).unwrap();
let result = controller.read_relay_state(relay_id).await;
// Should succeed if hardware is connected
assert!(
result.is_ok(),
"Failed to read state from real hardware: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_create_relay_controller_fallback_to_mock_after_retries() {
let settings = ModbusSettings {
host: "192.0.2.1".to_string(), // TEST-NET-1 (reserved, unreachable)
port: 502,
slave_id: 0,
timeout_secs: 1, // Short timeout for faster test
};
let start = std::time::Instant::now();
let controller = create_relay_controller(&settings, false).await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(5),
"Should have retried 3 times with 2s delays, took {elapsed:?}",
);
let relay_id = RelayId::new(1).unwrap();
let result = controller.read_relay_state(relay_id).await;
assert!(
result.is_ok() || result.is_err(),
"Controller should be usable (mock or real)"
);
}
#[tokio::test]
async fn test_create_relay_controller_retry_delays() {
let settings = ModbusSettings {
host: "192.0.2.1".to_string(), // Unreachable address
port: 502,
slave_id: 0,
timeout_secs: 1,
};
let start = std::time::Instant::now();
let _controller = create_relay_controller(&settings, false).await;
let elapsed = start.elapsed();
// Attempt 1 (1s timeout) + 2s delay + Attempt 2 (1s) + 2s delay + Attempt 3 (1s)
// = ~7 seconds minimum (allowing some variance)
assert!(
elapsed >= Duration::from_secs(7) && elapsed <= Duration::from_secs(15),
"Retry timing incorrect: expected ~7-15s, got {elapsed:?}",
);
}
}
-2
View File
@@ -5,7 +5,5 @@
/// Modbus TCP client for real hardware communication.
pub mod client;
/// Factory functions for creating relay controllers with retry and fallback logic.
pub mod factory;
/// Mock relay controller for testing without hardware.
pub mod mock_controller;
@@ -1,129 +0,0 @@
//! Factory module for creating relay label repository instances.
//!
//! This module provides factory functions for creating relay label repositories
//! with appropriate implementations based on configuration.
use std::sync::Arc;
use crate::{domain::relay::repository::{RelayLabelRepository, RepositoryError}, infrastructure::persistence::label_repository::MockRelayLabelRepository};
use super::sqlite_repository::SqliteRelayLabelRepository;
/// Creates a relay label repository based on configuration.
///
/// # Parameters
///
/// - `db_path`: Path to ``SQLite`` database file (e.g., "relays.db" or ":memory:")
/// - `use_mock`: If true, returns `MockRelayLabelRepository` for testing
///
/// # Returns
///
/// - `Ok(Arc<dyn RelayLabelRepository>)` on success
/// - `Err(RepositoryError)` if database connection fails or path is invalid
///
/// # Errors
///
/// Returns `RepositoryError` if:
/// - Database path is invalid or inaccessible
/// - ``SQLite`` connection fails
/// - Database schema migration fails
pub async fn create_label_repository(
db_path: &str,
use_mock: bool,
) -> Result<Arc<dyn RelayLabelRepository>, RepositoryError> {
if use_mock {
tracing::info!("Using MockRelayLabelRepository (test mode)");
return Ok(Arc::new(MockRelayLabelRepository::new()));
}
let repo = SqliteRelayLabelRepository::new(db_path).await?;
Ok(Arc::new(repo))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::relay::types::{RelayId, RelayLabel};
#[tokio::test]
async fn test_create_label_repository_with_mock_flag() {
let db_path = ":memory:";
let result = create_label_repository(db_path, true).await;
assert!(result.is_ok(), "Failed to create mock repository");
let repository = result.unwrap();
let relay_id = RelayId::new(1).unwrap();
let label_result = repository.get_label(relay_id).await;
assert!(
label_result.is_ok(),
"Mock repository should be immediately usable"
);
assert_eq!(
label_result.unwrap(),
None,
"Mock repository should start with no labels"
);
}
#[tokio::test]
async fn test_create_label_repository_with_sqlite() {
let db_path = ":memory:";
let result = create_label_repository(db_path, false).await;
assert!(result.is_ok(), "Failed to create SQLite repository");
let repository = result.unwrap();
let relay_id = RelayId::new(1).unwrap();
let label = RelayLabel::new("Pump".to_string()).unwrap();
let save_result = repository.save_label(relay_id, label.clone()).await;
assert!(
save_result.is_ok(),
"Failed to save label on SQLite repository"
);
let get_result = repository.get_label(relay_id).await;
assert!(get_result.is_ok(), "Failed to get label");
assert_eq!(get_result.unwrap(), Some(label));
}
#[tokio::test]
async fn test_create_label_repository_with_invalid_path() {
let db_path = "/nonexistent/directory/impossible/path/relays.db";
let result = create_label_repository(db_path, false).await;
assert!(result.is_err(), "Should fail with invalid database path");
if let Err(error) = result {
#[allow(clippy::match_wildcard_for_single_variants)]
match error {
RepositoryError::DatabaseError(_) => {
// Expected error type - test passes
}
_ => panic!("Expected DatabaseError for invalid path"),
}
}
}
#[tokio::test]
async fn test_mock_and_sqlite_repositories_are_independent() {
let mock_repo = create_label_repository(":memory:", true).await.unwrap();
let sqlite_repo = create_label_repository(":memory:", false).await.unwrap();
let relay_id = RelayId::new(1).unwrap();
let label = RelayLabel::new("Test".to_string()).unwrap();
mock_repo.save_label(relay_id, label.clone()).await.unwrap();
let sqlite_result = sqlite_repo.get_label(relay_id).await.unwrap();
assert_eq!(
sqlite_result, None,
"SQLite repository should be independent from mock"
);
}
#[tokio::test]
async fn test_in_memory_sqlite_does_not_persist() {
let relay_id = RelayId::new(1).unwrap();
let label = RelayLabel::new("Temporary".to_string()).unwrap();
{
let repo = create_label_repository(":memory:", false).await.unwrap();
repo.save_label(relay_id, label.clone()).await.unwrap();
} // repo is dropped here
let new_repo = create_label_repository(":memory:", false).await.unwrap();
let result = new_repo.get_label(relay_id).await.unwrap();
assert_eq!(
result, None,
"In-memory database should not persist across instances"
);
}
}
@@ -12,17 +12,22 @@
#[cfg(test)]
mod relay_label_repository_contract_tests {
use crate::{
domain::relay::{
use crate::domain::relay::{
repository::RelayLabelRepository,
types::{RelayId, RelayLabel},
},
infrastructure::persistence::label_repository::MockRelayLabelRepository,
};
#[tokio::test]
pub async fn test_get_label_returns_none_for_non_existent_relay() {
let repo = MockRelayLabelRepository::new();
// =========================================================================
// get_label() Tests
// =========================================================================
/// Test: `get_label` returns None for non-existent relay
///
/// Verifies that querying a relay ID that has no label returns None
/// rather than an error.
pub async fn test_get_label_returns_none_for_non_existent_relay<R: RelayLabelRepository>(
repo: &R,
) {
let relay_id = RelayId::new(1).expect("Valid relay ID");
let result = repo.get_label(relay_id).await;
@@ -34,16 +39,19 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_get_label_retrieves_saved_label() {
let repo = MockRelayLabelRepository::new();
/// Test: `get_label` retrieves previously saved label
///
/// Verifies that after saving a label, `get_label` returns the same label.
pub async fn test_get_label_retrieves_saved_label<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(2).expect("Valid relay ID");
let label = RelayLabel::new("Heater".to_string()).expect("Valid label");
// Save the label
repo.save_label(relay_id, label.clone())
.await
.expect("save_label should succeed");
// Retrieve the label
let result = repo.get_label(relay_id).await;
assert!(result.is_ok(), "get_label should succeed");
@@ -56,12 +64,14 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_get_label_returns_none_after_delete() {
let repo = MockRelayLabelRepository::new();
/// Test: `get_label` returns None after label is deleted
///
/// Verifies that after deleting a label, `get_label` returns None.
pub async fn test_get_label_returns_none_after_delete<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(3).expect("Valid relay ID");
let label = RelayLabel::new("ToBeDeleted".to_string()).expect("Valid label");
// Save and then delete the label
repo.save_label(relay_id, label)
.await
.expect("save_label should succeed");
@@ -69,6 +79,7 @@ mod relay_label_repository_contract_tests {
.await
.expect("delete_label should succeed");
// Verify it's gone
let result = repo.get_label(relay_id).await;
assert!(result.is_ok(), "get_label should succeed");
assert!(
@@ -77,9 +88,14 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_save_label_succeeds() {
let repo = MockRelayLabelRepository::new();
// =========================================================================
// save_label() Tests
// =========================================================================
/// Test: `save_label` successfully saves a label
///
/// Verifies that `save_label` returns Ok and stores the label.
pub async fn test_save_label_succeeds<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(1).expect("Valid relay ID");
let label = RelayLabel::new("Pump".to_string()).expect("Valid label");
@@ -88,21 +104,26 @@ mod relay_label_repository_contract_tests {
assert!(result.is_ok(), "save_label should succeed");
}
#[tokio::test]
pub async fn test_save_label_overwrites_existing_label() {
let repo = MockRelayLabelRepository::new();
/// Test: `save_label` overwrites existing label
///
/// Verifies that calling `save_label` multiple times for the same relay ID
/// replaces the old label with the new one.
pub async fn test_save_label_overwrites_existing_label<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(4).expect("Valid relay ID");
let label1 = RelayLabel::new("First".to_string()).expect("Valid label");
let label2 = RelayLabel::new("Second".to_string()).expect("Valid label");
// Save first label
repo.save_label(relay_id, label1)
.await
.expect("First save should succeed");
// Overwrite with second label
repo.save_label(relay_id, label2)
.await
.expect("Second save should succeed");
// Verify only the second label is present
let result = repo
.get_label(relay_id)
.await
@@ -115,9 +136,10 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_save_label_for_all_valid_relay_ids() {
let repo = MockRelayLabelRepository::new();
/// Test: `save_label` works for all valid relay IDs (1-8)
///
/// Verifies that all relay IDs in the valid range can have labels saved.
pub async fn test_save_label_for_all_valid_relay_ids<R: RelayLabelRepository>(repo: &R) {
for id in 1..=8 {
let relay_id = RelayId::new(id).expect("Valid relay ID");
let label = RelayLabel::new(format!("Relay {id}")).expect("Valid label");
@@ -129,6 +151,7 @@ mod relay_label_repository_contract_tests {
);
}
// Verify all labels were saved
let all_labels = repo
.get_all_labels()
.await
@@ -136,9 +159,11 @@ mod relay_label_repository_contract_tests {
assert_eq!(all_labels.len(), 8, "Should have all 8 relay labels");
}
#[tokio::test]
pub async fn test_save_label_accepts_max_length_labels() {
let repo = MockRelayLabelRepository::new();
/// Test: `save_label` accepts maximum length labels
///
/// Verifies that labels at the maximum allowed length (50 characters)
/// can be saved successfully.
pub async fn test_save_label_accepts_max_length_labels<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(5).expect("Valid relay ID");
let max_label = RelayLabel::new("A".repeat(50)).expect("Valid max-length label");
@@ -148,6 +173,7 @@ mod relay_label_repository_contract_tests {
"save_label should succeed with max-length label"
);
// Verify it was saved correctly
let retrieved = repo
.get_label(relay_id)
.await
@@ -160,9 +186,11 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_save_label_accepts_min_length_labels() {
let repo = MockRelayLabelRepository::new();
/// Test: `save_label` accepts minimum length labels
///
/// Verifies that labels at the minimum allowed length (1 character)
/// can be saved successfully.
pub async fn test_save_label_accepts_min_length_labels<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(6).expect("Valid relay ID");
let min_label = RelayLabel::new("X".to_string()).expect("Valid min-length label");
@@ -172,6 +200,7 @@ mod relay_label_repository_contract_tests {
"save_label should succeed with min-length label"
);
// Verify it was saved correctly
let retrieved = repo
.get_label(relay_id)
.await
@@ -180,25 +209,37 @@ mod relay_label_repository_contract_tests {
assert_eq!(retrieved.unwrap().as_str(), "X", "Label should match");
}
#[tokio::test]
pub async fn test_delete_label_succeeds_for_existing_label() {
let repo = MockRelayLabelRepository::new();
// =========================================================================
// delete_label() Tests
// =========================================================================
/// Test: `delete_label` succeeds for existing label
///
/// Verifies that `delete_label` returns Ok when deleting an existing label.
pub async fn test_delete_label_succeeds_for_existing_label<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(7).expect("Valid relay ID");
let label = RelayLabel::new("ToDelete".to_string()).expect("Valid label");
// Save the label first
repo.save_label(relay_id, label)
.await
.expect("save_label should succeed");
// Delete it
let result = repo.delete_label(relay_id).await;
assert!(result.is_ok(), "delete_label should succeed");
}
#[tokio::test]
pub async fn test_delete_label_succeeds_for_non_existent_label() {
let repo = MockRelayLabelRepository::new();
/// Test: `delete_label` succeeds for non-existent label
///
/// Verifies that `delete_label` returns Ok even when no label exists
/// (idempotent operation).
pub async fn test_delete_label_succeeds_for_non_existent_label<R: RelayLabelRepository>(
repo: &R,
) {
let relay_id = RelayId::new(8).expect("Valid relay ID");
// Delete without saving first
let result = repo.delete_label(relay_id).await;
assert!(
result.is_ok(),
@@ -206,14 +247,19 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_delete_label_removes_label_from_repository() {
let repo = MockRelayLabelRepository::new();
/// Test: `delete_label` removes label from repository
///
/// Verifies that after deleting a label, it no longer appears in `get_label`
/// or `get_all_labels` results.
pub async fn test_delete_label_removes_label_from_repository<R: RelayLabelRepository>(
repo: &R,
) {
let relay1 = RelayId::new(1).expect("Valid relay ID");
let relay2 = RelayId::new(2).expect("Valid relay ID");
let label1 = RelayLabel::new("Keep".to_string()).expect("Valid label");
let label2 = RelayLabel::new("Remove".to_string()).expect("Valid label");
// Save two labels
repo.save_label(relay1, label1)
.await
.expect("save should succeed");
@@ -221,22 +267,26 @@ mod relay_label_repository_contract_tests {
.await
.expect("save should succeed");
// Delete one label
repo.delete_label(relay2)
.await
.expect("delete should succeed");
// Verify deleted label is gone
let get_result = repo
.get_label(relay2)
.await
.expect("get_label should succeed");
assert!(get_result.is_none(), "Deleted label should not exist");
// Verify other label still exists
let other_result = repo
.get_label(relay1)
.await
.expect("get_label should succeed");
assert!(other_result.is_some(), "Other label should still exist");
// Verify get_all_labels only returns the remaining label
let all_labels = repo
.get_all_labels()
.await
@@ -245,12 +295,14 @@ mod relay_label_repository_contract_tests {
assert_eq!(all_labels[0].0.as_u8(), 1, "Should be relay 1");
}
#[tokio::test]
pub async fn test_delete_label_is_idempotent() {
let repo = MockRelayLabelRepository::new();
/// Test: `delete_label` is idempotent
///
/// Verifies that calling `delete_label` multiple times succeeds without error.
pub async fn test_delete_label_is_idempotent<R: RelayLabelRepository>(repo: &R) {
let relay_id = RelayId::new(3).expect("Valid relay ID");
let label = RelayLabel::new("Idempotent".to_string()).expect("Valid label");
// Save, then delete twice
repo.save_label(relay_id, label)
.await
.expect("save should succeed");
@@ -265,9 +317,17 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_get_all_labels_returns_empty_when_no_labels() {
let repo = MockRelayLabelRepository::new();
// =========================================================================
// get_all_labels() Tests
// =========================================================================
/// Test: `get_all_labels` returns empty vector when no labels exist
///
/// Verifies that `get_all_labels` returns an empty vector rather than
/// an error when the repository is empty.
pub async fn test_get_all_labels_returns_empty_when_no_labels<R: RelayLabelRepository>(
repo: &R,
) {
let result = repo.get_all_labels().await;
assert!(result.is_ok(), "get_all_labels should succeed");
@@ -277,9 +337,11 @@ mod relay_label_repository_contract_tests {
);
}
#[tokio::test]
pub async fn test_get_all_labels_returns_all_saved_labels() {
let repo = MockRelayLabelRepository::new();
/// Test: `get_all_labels` returns all saved labels
///
/// Verifies that `get_all_labels` returns all labels that have been saved,
/// and only those relays with labels.
pub async fn test_get_all_labels_returns_all_saved_labels<R: RelayLabelRepository>(repo: &R) {
let relay1 = RelayId::new(1).expect("Valid relay ID");
let relay3 = RelayId::new(3).expect("Valid relay ID");
let relay5 = RelayId::new(5).expect("Valid relay ID");
@@ -288,6 +350,7 @@ mod relay_label_repository_contract_tests {
let label3 = RelayLabel::new("Heater".to_string()).expect("Valid label");
let label5 = RelayLabel::new("Fan".to_string()).expect("Valid label");
// Save labels
repo.save_label(relay1, label1.clone())
.await
.expect("Save should succeed");
@@ -298,6 +361,7 @@ mod relay_label_repository_contract_tests {
.await
.expect("Save should succeed");
// Retrieve all labels
let result = repo
.get_all_labels()
.await
@@ -305,6 +369,7 @@ mod relay_label_repository_contract_tests {
assert_eq!(result.len(), 3, "Should return exactly 3 labels");
// Verify the labels are present (order may vary by implementation)
let has_relay1 = result
.iter()
.any(|(id, label)| id.as_u8() == 1 && label.as_str() == "Pump");
@@ -320,9 +385,13 @@ mod relay_label_repository_contract_tests {
assert!(has_relay5, "Should contain relay 5 with label 'Fan'");
}
#[tokio::test]
pub async fn test_get_all_labels_excludes_relays_without_labels() {
let repo = MockRelayLabelRepository::new();
/// Test: `get_all_labels` excludes relays without labels
///
/// Verifies that only relays with labels are returned, not all possible
/// relay IDs (1-8).
pub async fn test_get_all_labels_excludes_relays_without_labels<R: RelayLabelRepository>(
repo: &R,
) {
let relay2 = RelayId::new(2).expect("Valid relay ID");
let label2 = RelayLabel::new("Only This One".to_string()).expect("Valid label");
@@ -343,9 +412,10 @@ mod relay_label_repository_contract_tests {
assert_eq!(result[0].0.as_u8(), 2, "Should be relay 2");
}
#[tokio::test]
pub async fn test_get_all_labels_excludes_deleted_labels() {
let repo = MockRelayLabelRepository::new();
/// Test: `get_all_labels` excludes deleted labels
///
/// Verifies that deleted labels don't appear in `get_all_labels` results.
pub async fn test_get_all_labels_excludes_deleted_labels<R: RelayLabelRepository>(repo: &R) {
let relay1 = RelayId::new(1).expect("Valid relay ID");
let relay2 = RelayId::new(2).expect("Valid relay ID");
let relay3 = RelayId::new(3).expect("Valid relay ID");
@@ -354,6 +424,7 @@ mod relay_label_repository_contract_tests {
let label2 = RelayLabel::new("Delete".to_string()).expect("Valid label");
let label3 = RelayLabel::new("Keep2".to_string()).expect("Valid label");
// Save all three labels
repo.save_label(relay1, label1)
.await
.expect("save should succeed");
@@ -364,10 +435,12 @@ mod relay_label_repository_contract_tests {
.await
.expect("save should succeed");
// Delete the middle one
repo.delete_label(relay2)
.await
.expect("delete should succeed");
// Verify get_all_labels only returns the two remaining labels
let result = repo
.get_all_labels()
.await
@@ -3,11 +3,6 @@
//! This module contains the concrete implementations of repository traits
//! for data persistence, including SQLite-based storage for relay labels.
pub mod entities;
/// Factory functions for creating relay label repositories.
pub mod factory;
/// Mock repository implementation for testing.
pub mod label_repository;
@@ -17,3 +12,5 @@ pub mod label_repository_tests;
/// `SQLite` repository implementation for relay labels.
pub mod sqlite_repository;
pub mod entities;
+5 -6
View File
@@ -85,7 +85,7 @@ pub mod presentation;
type MaybeListener = Option<poem::listener::TcpListener<String>>;
async fn prepare(listener: MaybeListener) -> startup::Application {
fn prepare(listener: MaybeListener) -> startup::Application {
dotenvy::dotenv().ok();
let settings = settings::Settings::new().expect("Failed to read settings");
if !cfg!(test) {
@@ -98,8 +98,7 @@ async fn prepare(listener: MaybeListener) -> startup::Application {
"Using these settings: {:?}",
settings
);
let application = startup::Application::build(settings, listener).await
.expect("Failed to build application");
let application = startup::Application::build(settings, listener);
tracing::event!(
target: "backend",
tracing::Level::INFO,
@@ -125,7 +124,7 @@ async fn prepare(listener: MaybeListener) -> startup::Application {
/// an I/O error during runtime (e.g., port already in use, network issues).
#[cfg(not(tarpaulin_include))]
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
let application = prepare(listener).await;
let application = prepare(listener);
application.make_app().run().await
}
@@ -138,7 +137,7 @@ fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
}
#[cfg(test)]
async fn get_test_app() -> startup::App {
fn get_test_app() -> startup::App {
let tcp_listener = make_random_tcp_listener();
prepare(Some(tcp_listener)).await.make_app().into()
prepare(Some(tcp_listener)).make_app().into()
}
-1
View File
@@ -1 +0,0 @@
pub mod relay_api;
-259
View File
@@ -1,259 +0,0 @@
use std::sync::Arc;
use poem::Result;
use poem_openapi::{ApiResponse, OpenApi, param::Path, payload::Json};
use crate::{
application::use_cases::{GetAllRelaysUseCase, ToggleRelayUseCase},
domain::relay::{
Relay, controller::RelayController, repository::RelayLabelRepository, types::RelayId,
},
presentation::{dto::relay_dto::RelayDto, error::ApiError},
route::ApiCategory
};
#[derive(ApiResponse)]
enum GetAllRelaysResponse {
#[oai(status = 200)]
Ok(Json<Vec<RelayDto>>),
}
#[derive(ApiResponse)]
enum ToggleRelayResponse {
#[oai(status = 200)]
Ok(Json<RelayDto>),
}
pub struct RelayApi {
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
}
impl RelayApi {
pub fn new(
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
) -> Self {
Self {
relay_controller,
label_repository,
}
}
}
// -- Endpoints ---
#[OpenApi(tag = "ApiCategory::Relays")]
impl RelayApi {
#[oai(path = "/relays", method = "get")]
async fn get_all_relays(&self) -> Result<GetAllRelaysResponse> {
let use_case =
GetAllRelaysUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
let relays = use_case
.execute()
.await
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
let dtos: Vec<_> = relays
.into_iter()
.map(|r| {
let domain_relay =
Relay::with_label(r.id(), r.state(), r.label().unwrap_or_default());
RelayDto::from(domain_relay)
})
.collect();
Ok(GetAllRelaysResponse::Ok(Json(dtos)))
}
#[oai(path = "/relays/:id/toggle", method = "post")]
async fn toggle_relay(&self, id: Path<u8>) -> Result<ToggleRelayResponse> {
let relay_id =
RelayId::new(*id).map_err(|_| poem::Error::from(ApiError::RelayNotFound(*id)))?;
let use_case =
ToggleRelayUseCase::new(self.relay_controller.clone(), self.label_repository.clone());
let relay = use_case
.execute(relay_id)
.await
.map_err(|e| poem::Error::from(ApiError::from(e)))?;
let domain_relay =
Relay::with_label(relay.id(), relay.state(), relay.label().unwrap_or_default());
Ok(ToggleRelayResponse::Ok(Json(RelayDto::from(domain_relay))))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use poem::http::StatusCode;
use poem_openapi::OpenApiService;
use crate::{
domain::relay::{
controller::RelayController,
repository::RelayLabelRepository,
types::{RelayId, RelayState},
},
infrastructure::{
modbus::mock_controller::MockRelayController,
persistence::label_repository::MockRelayLabelRepository,
},
};
use super::RelayApi;
fn make_relay_api(controller: Arc<MockRelayController>) -> poem::test::TestClient<impl poem::Endpoint> {
let repo = Arc::new(MockRelayLabelRepository::new());
let relay_api = RelayApi::new(controller, repo);
let api_service = OpenApiService::new(relay_api, "test", "1.0");
let app = poem::Route::new().nest("/api", api_service);
poem::test::TestClient::new(app)
}
// -- GET /api/relays --
#[tokio::test]
async fn get_all_relays_returns_200() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn get_all_relays_returns_empty_array_when_no_states() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert!(body.is_empty());
}
#[tokio::test]
async fn get_all_relays_returns_all_initialized_relays() {
let controller = Arc::new(MockRelayController::new());
for i in 1u8..=8 {
controller
.write_relay_state(RelayId::new(i).unwrap(), RelayState::Off)
.await
.unwrap();
}
controller
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert_eq!(body.len(), 8);
assert_eq!(body[0]["id"], 1);
assert_eq!(body[0]["state"], "on");
assert_eq!(body[1]["id"], 2);
assert_eq!(body[1]["state"], "off");
}
// -- POST /api/relays/{id}/toggle --
#[tokio::test]
async fn toggle_relay_with_out_of_range_id_9_returns_404() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/9/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_relay_with_id_0_returns_404() {
let controller = Arc::new(MockRelayController::new());
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/0/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_relay_toggles_off_to_on_and_returns_200() {
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(1).unwrap(), RelayState::Off)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["id"], 1);
assert_eq!(body["state"], "on");
}
#[tokio::test]
async fn toggle_relay_toggles_on_to_off_and_returns_200() {
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
.await
.unwrap();
let cli = make_relay_api(controller);
let resp = cli.post("/api/relays/3/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["id"], 3);
assert_eq!(body["state"], "off");
}
#[tokio::test]
async fn toggle_relay_includes_label_in_response() {
use crate::domain::relay::types::RelayLabel;
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(2).unwrap(), RelayState::Off)
.await
.unwrap();
let repo = Arc::new(MockRelayLabelRepository::new());
repo.save_label(RelayId::new(2).unwrap(), RelayLabel::new("Pump".to_string()).unwrap())
.await
.unwrap();
let relay_api = RelayApi::new(controller, repo);
let api_service = OpenApiService::new(relay_api, "test", "1.0");
let app = poem::Route::new().nest("/api", api_service);
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/2/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["label"], "Pump");
}
// -- Integration tests via get_test_app() --
#[tokio::test]
async fn get_all_relays_endpoint_reachable_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
}
#[tokio::test]
async fn toggle_relay_invalid_id_returns_404_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/9/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
// Posting to a valid relay ID on an empty mock should hit the handler (route found)
// and return 500 because the mock controller has no relay state initialised.
#[tokio::test]
async fn toggle_relay_valid_id_empty_mock_returns_500_via_full_app() {
let app = crate::get_test_app().await;
let cli = poem::test::TestClient::new(app);
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
}
}
-6
View File
@@ -1,6 +0,0 @@
/// Relay-specific Data Transfer Objects.
///
/// This module contains DTO structures for relay-related API responses,
/// providing serialized representations of relay domain objects for
/// external consumption.
pub mod relay_dto;
-194
View File
@@ -1,194 +0,0 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::domain::relay::Relay;
/// Data Transfer Object for relay information.
///
/// This struct represents a relay in a serialized format suitable for API
/// responses. It contains the relay's ID, current state, and label in a
/// format that can be easily serialized to JSON.
#[derive(Object, Serialize, Deserialize)]
pub struct RelayDto {
/// The relay's unique identifier (1-8).
id: u8,
/// The relay's current state as a string ("on" or "off").
state: String,
/// The relay's user-friendly label.
label: String,
}
impl From<Relay> for RelayDto {
/// Converts a domain Relay object to a `RelayDto`.
///
/// This conversion extracts the relay's ID, state, and label from the
/// domain object and formats them for API consumption.
///
/// # Arguments
///
/// * `value` - The Relay domain object to convert
///
/// # Returns
///
/// A `RelayDto` containing the relay's data in serialized format
fn from(value: Relay) -> Self {
let id = value.id().as_u8();
let state = value.state().to_string();
let label = value.label().to_string();
Self { id, state, label }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::relay::types::{RelayId, RelayLabel, RelayState};
#[test]
fn test_relay_dto_from_relay_with_default_label() {
// Test: Relay with default label converts to RelayDto with None label
let relay_id = RelayId::new(1).unwrap();
let relay = Relay::new(relay_id);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 1);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "Unlabeled".to_string());
}
#[test]
fn test_relay_dto_from_relay_with_custom_label() {
// Test: Relay with custom label converts to RelayDto with Some(label)
let relay_id = RelayId::new(2).unwrap();
let label = RelayLabel::new("Water Pump".to_string()).unwrap();
let relay = Relay::with_label(relay_id, RelayState::On, label);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 2);
assert_eq!(dto.state, "on");
assert_eq!(dto.label, "Water Pump".to_string());
}
#[test]
fn test_relay_dto_from_relay_with_on_state() {
// Test: Relay with On state converts to RelayDto with "on" state
let relay_id = RelayId::new(3).unwrap();
let relay = Relay::with_state(relay_id, RelayState::On);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 3);
assert_eq!(dto.state, "on");
assert_eq!(dto.label, "Unlabeled".to_string());
}
#[test]
fn test_relay_dto_from_relay_with_off_state() {
// Test: Relay with Off state converts to RelayDto with "off" state
let relay_id = RelayId::new(4).unwrap();
let relay = Relay::with_state(relay_id, RelayState::Off);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 4);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "Unlabeled".to_string());
}
#[test]
fn test_relay_dto_from_relay_with_max_length_label() {
// Test: Relay with maximum length label (50 chars) converts correctly
let relay_id = RelayId::new(5).unwrap();
let max_label = RelayLabel::new("A".repeat(50)).unwrap();
let relay = Relay::with_label(relay_id, RelayState::Off, max_label);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 5);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "A".repeat(50));
}
#[test]
fn test_relay_dto_from_relay_with_empty_label_becomes_none() {
let relay_id = RelayId::new(6).unwrap();
let relay = Relay::new(relay_id);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, 6);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "Unlabeled".to_string());
}
#[test]
fn test_relay_dto_serialization() {
// Test: RelayDto can be serialized to JSON
let relay_id = RelayId::new(7).unwrap();
let label = RelayLabel::new("Test Relay".to_string()).unwrap();
let relay = Relay::with_label(relay_id, RelayState::On, label);
let dto = RelayDto::from(relay);
let json = serde_json::to_string(&dto).unwrap();
assert_eq!(json, r#"{"id":7,"state":"on","label":"Test Relay"}"#);
}
#[test]
fn test_relay_dto_deserialization() {
// Test: RelayDto can be deserialized from JSON
let json = r#"{"id":8,"state":"off","label":"Another Relay"}"#;
let dto: RelayDto = serde_json::from_str(json).unwrap();
assert_eq!(dto.id, 8);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "Another Relay".to_string());
}
#[test]
fn test_relay_dto_all_valid_relay_ids() {
// Test: All valid relay IDs (1-8) convert correctly
for id_val in 1..=8 {
let relay_id = RelayId::new(id_val).unwrap();
let relay = Relay::new(relay_id);
let dto = RelayDto::from(relay);
assert_eq!(dto.id, id_val);
assert_eq!(dto.state, "off");
assert_eq!(dto.label, "Unlabeled".to_string());
}
}
#[test]
fn test_relay_dto_state_toggle_reflected() {
// Test: Relay state changes are reflected in DTO
let relay_id = RelayId::new(1).unwrap();
let mut relay = Relay::with_state(relay_id, RelayState::Off);
// Initial state
let dto1 = RelayDto::from(relay.clone());
assert_eq!(dto1.state, "off");
// After toggle
relay.toggle();
let dto2 = RelayDto::from(relay.clone());
assert_eq!(dto2.state, "on");
// After another toggle
relay.toggle();
let dto3 = RelayDto::from(relay);
assert_eq!(dto3.state, "off");
}
#[test]
fn test_relay_dto_label_change_reflected() {
// Test: Relay label changes are reflected in DTO
let relay_id = RelayId::new(2).unwrap();
let mut relay = Relay::new(relay_id);
// Initial label (default)
let dto1 = RelayDto::from(relay.clone());
assert_eq!(dto1.label, "Unlabeled".to_string());
// After setting custom label
let new_label = RelayLabel::new("Custom Label".to_string()).unwrap();
relay.set_label(new_label);
let dto2 = RelayDto::from(relay);
assert_eq!(dto2.label, "Custom Label".to_string());
}
}
-219
View File
@@ -1,219 +0,0 @@
//! API error types for the presentation layer.
//!
//! Defines [`ApiError`], the single error type returned by all API handlers.
//! Each variant maps to an appropriate HTTP status code via [`poem::error::ResponseError`].
use poem::{error::ResponseError, http::StatusCode};
use crate::{
application::use_cases::{get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError},
domain::relay::{
controller::ControllerError, repository::RepositoryError, types::RelayLabelError,
},
};
/// Unified error type for all API handlers.
///
/// Variants cover every failure mode that can reach the presentation layer and
/// map each one to a semantically appropriate HTTP status code.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
/// Relay ID is outside the valid range 1-8, error 404
#[error("Relay not found: ID {0} is outside the valid range (1-8)")]
RelayNotFound(u8),
/// Input validation failed (e.g. empty or too long label), error 400
#[error("Bad request: {0}")]
BadRequest(String),
/// Hardware controller failure, error 503 or 504
#[error("Controller error: {0}")]
ControllerError(#[from] ControllerError),
/// Database / repository failure, error 500
#[error("Repository error: {0}")]
RepositoryError(#[from] RepositoryError),
}
impl ResponseError for ApiError {
fn status(&self) -> poem::http::StatusCode {
match self {
Self::RelayNotFound(_) => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::ControllerError(e) => match e {
ControllerError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
ControllerError::ConnectionError(_) | ControllerError::ModbusException(_) => {
StatusCode::SERVICE_UNAVAILABLE
}
// InvalidRelayId and InvalidInput are programmer errors at this layer
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
Self::RepositoryError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl From<RelayLabelError> for ApiError {
fn from(value: RelayLabelError) -> Self {
Self::BadRequest(value.to_string())
}
}
impl From<GetAllRelaysError> for ApiError {
fn from(value: GetAllRelaysError) -> Self {
match value {
GetAllRelaysError::Controller(e) => Self::ControllerError(e),
GetAllRelaysError::Repository(e) => Self::RepositoryError(e),
}
}
}
impl From<ToggleRelayError> for ApiError {
fn from(value: ToggleRelayError) -> Self {
match value {
ToggleRelayError::Controller(e) => Self::ControllerError(e),
ToggleRelayError::Repository(e) => Self::RepositoryError(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use poem::error::ResponseError;
use poem::http::StatusCode;
use crate::{
application::use_cases::{
get_all_relays::GetAllRelaysError, toggle_relay::ToggleRelayError,
},
domain::relay::{
controller::ControllerError,
repository::RepositoryError,
types::{RelayId, RelayLabelError},
},
};
// --- Status code mapping ---
#[test]
fn test_relay_not_found_returns_404() {
let error = ApiError::RelayNotFound(9);
assert_eq!(error.status(), StatusCode::NOT_FOUND);
}
#[test]
fn test_bad_request_returns_400() {
let error = ApiError::BadRequest("invalid input".to_string());
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_controller_timeout_returns_504() {
let error = ApiError::ControllerError(ControllerError::Timeout(5));
assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn test_controller_connection_error_returns_503() {
let error =
ApiError::ControllerError(ControllerError::ConnectionError("refused".to_string()));
assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn test_controller_modbus_exception_returns_503() {
let error = ApiError::ControllerError(ControllerError::ModbusException(
"illegal function".to_string(),
));
assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn test_controller_invalid_relay_id_returns_500() {
let error = ApiError::ControllerError(ControllerError::InvalidRelayId(9));
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_controller_invalid_input_returns_500() {
let error =
ApiError::ControllerError(ControllerError::InvalidInput("bad input".to_string()));
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_repository_error_returns_500() {
let error =
ApiError::RepositoryError(RepositoryError::DatabaseError("db failed".to_string()));
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
// --- From<RelayLabelError> ---
#[test]
fn test_from_relay_label_error_empty_produces_bad_request() {
let api_error = ApiError::from(RelayLabelError::Empty);
assert!(matches!(api_error, ApiError::BadRequest(_)));
}
#[test]
fn test_from_relay_label_error_too_long_produces_bad_request() {
let api_error = ApiError::from(RelayLabelError::TooLong(51));
assert!(matches!(api_error, ApiError::BadRequest(_)));
}
// --- From<GetAllRelaysError> ---
#[test]
fn test_from_get_all_relays_controller_error_produces_controller_error() {
let source = GetAllRelaysError::Controller(ControllerError::Timeout(5));
let api_error = ApiError::from(source);
assert!(matches!(api_error, ApiError::ControllerError(_)));
}
#[test]
fn test_from_get_all_relays_repository_error_produces_repository_error() {
let source =
GetAllRelaysError::Repository(RepositoryError::DatabaseError("err".to_string()));
let api_error = ApiError::from(source);
assert!(matches!(api_error, ApiError::RepositoryError(_)));
}
// --- From<ToggleRelayError> ---
#[test]
fn test_from_toggle_relay_controller_error_produces_controller_error() {
let source = ToggleRelayError::Controller(ControllerError::Timeout(5));
let api_error = ApiError::from(source);
assert!(matches!(api_error, ApiError::ControllerError(_)));
}
#[test]
fn test_from_toggle_relay_repository_error_produces_repository_error() {
let relay_id = RelayId::new(1).unwrap();
let source = ToggleRelayError::Repository(RepositoryError::NotFound(relay_id));
let api_error = ApiError::from(source);
assert!(matches!(api_error, ApiError::RepositoryError(_)));
}
// --- Error messages ---
#[test]
fn test_relay_not_found_error_message() {
let error = ApiError::RelayNotFound(5);
assert_eq!(
error.to_string(),
"Relay not found: ID 5 is outside the valid range (1-8)"
);
}
#[test]
fn test_bad_request_error_message() {
let error = ApiError::BadRequest("invalid label".to_string());
assert_eq!(error.to_string(), "Bad request: invalid label");
}
#[test]
fn test_relay_label_error_message_preserved_in_bad_request() {
let api_error = ApiError::from(RelayLabelError::Empty);
assert_eq!(api_error.to_string(), "Bad request: Label cannot be empty");
}
}
-9
View File
@@ -94,12 +94,3 @@
//! - Architecture: `specs/constitution.md` - API-First Design principle
//! - API design: `specs/001-modbus-relay-control/plan.md` - Presentation layer tasks
//! - Domain types: [`crate::domain`] - Types to be wrapped in DTOs
/// Data Transfer Objects (DTOs) for API responses.
///
/// This module contains DTO structures that are used to serialize domain
/// objects for API responses, providing a clean separation between internal
/// domain models and external API contracts.
pub mod api;
pub mod dto;
pub mod error;
+1 -1
View File
@@ -30,7 +30,7 @@ impl HealthApi {
#[tokio::test]
async fn health_check_works() {
let app = crate::get_test_app().await;
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/health").send().await;
resp.assert_status_is_ok();
+2 -2
View File
@@ -59,7 +59,7 @@ impl MetaApi {
mod tests {
#[tokio::test]
async fn meta_endpoint_returns_correct_data() {
let app = crate::get_test_app().await;
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/meta").send().await;
resp.assert_status_is_ok();
@@ -78,7 +78,7 @@ mod tests {
#[tokio::test]
async fn meta_endpoint_returns_200_status() {
let app = crate::get_test_app().await;
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/meta").send().await;
resp.assert_status_is_ok();
+1 -2
View File
@@ -12,10 +12,9 @@ mod meta;
use crate::settings::Settings;
#[derive(Tags)]
pub enum ApiCategory {
enum ApiCategory {
Health,
Meta,
Relays,
}
pub(crate) struct Api {
-16
View File
@@ -1,16 +0,0 @@
/// Application-specific configuration settings.
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct ApplicationSettings {
/// Application name
pub name: String,
/// Application version
pub version: String,
/// Port to bind to
pub port: u16,
/// Host address to bind to
pub host: String,
/// Base URL of the application
pub base_url: String,
/// Protocol (http or https)
pub protocol: String,
}
-2
View File
@@ -59,10 +59,8 @@ impl From<CorsSettings> for Cors {
);
let mut cors = Self::new();
for origin in &val.allowed_origins {
if origin != "*" {
cors = cors.allow_origin(origin);
}
}
cors = cors.allow_methods(vec![
Method::GET,
Method::POST,
-12
View File
@@ -1,12 +0,0 @@
#[derive(Debug, serde::Deserialize, Clone)]
pub struct DatabaseSettings {
pub path: String,
}
impl Default for DatabaseSettings {
fn default() -> Self {
Self {
path: "sqlite::memory:".to_string(),
}
}
}
-134
View File
@@ -1,134 +0,0 @@
/// Application environment.
#[derive(Debug, PartialEq, Eq, Default)]
pub enum Environment {
/// Development environment
#[default]
Development,
/// Production environment
Production,
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Development => "development",
Self::Production => "production",
};
write!(f, "{self_str}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Environment {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"production" | "prod" => Ok(Self::Production),
other => Err(format!(
"{other} is not a supported environment. Use either `development` or `production`"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_display_development() {
let env = Environment::Development;
assert_eq!(env.to_string(), "development");
}
#[test]
fn environment_display_production() {
let env = Environment::Production;
assert_eq!(env.to_string(), "production");
}
#[test]
fn environment_from_str_development() {
assert_eq!(
Environment::try_from("development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("dev").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("Development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("DEV").unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_str_production() {
assert_eq!(
Environment::try_from("production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("prod").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("Production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("PROD").unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_str_invalid() {
let result = Environment::try_from("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a supported environment"));
}
#[test]
fn environment_from_string_development() {
assert_eq!(
Environment::try_from("development".to_string()).unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_string_production() {
assert_eq!(
Environment::try_from("production".to_string()).unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_string_invalid() {
let result = Environment::try_from("invalid".to_string());
assert!(result.is_err());
}
#[test]
fn environment_default_is_development() {
let env = Environment::default();
assert_eq!(env, Environment::Development);
}
}
+271 -19
View File
@@ -7,21 +7,8 @@
//! Settings include application details, Modbus connection parameters, relay configuration,
//! rate limiting, and environment settings.
mod application;
mod cors;
mod database;
mod environment;
mod modbus;
mod rate_limiting;
mod relay;
pub use application::ApplicationSettings;
pub use cors::CorsSettings;
pub use database::DatabaseSettings;
pub use environment::Environment;
pub use modbus::ModbusSettings;
pub use rate_limiting::RateLimitSettings;
pub use relay::RelaySettings;
/// Application configuration settings.
///
@@ -31,21 +18,15 @@ pub struct Settings {
/// Application-specific settings (name, version, host, port, etc.)
pub application: ApplicationSettings,
/// Debug mode flag
#[serde(default)]
pub debug: bool,
/// Frontend URL for CORS configuration
pub frontend_url: String,
/// Database settings
#[serde(default)]
pub database: DatabaseSettings,
/// Rate limiting configuration
#[serde(default)]
pub rate_limit: RateLimitSettings,
/// Modbus configuration
#[serde(default)]
pub modbus: ModbusSettings,
/// Relay configuration
#[serde(default)]
pub relay: RelaySettings,
/// CORS configuration
#[serde(default)]
@@ -97,10 +78,272 @@ impl Settings {
}
}
/// Application-specific configuration settings.
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct ApplicationSettings {
/// Application name
pub name: String,
/// Application version
pub version: String,
/// Port to bind to
pub port: u16,
/// Host address to bind to
pub host: String,
/// Base URL of the application
pub base_url: String,
/// Protocol (http or https)
pub protocol: String,
}
/// Application environment.
#[derive(Debug, PartialEq, Eq, Default)]
pub enum Environment {
/// Development environment
#[default]
Development,
/// Production environment
Production,
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Development => "development",
Self::Production => "production",
};
write!(f, "{self_str}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Environment {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"production" | "prod" => Ok(Self::Production),
other => Err(format!(
"{other} is not a supported environment. Use either `development` or `production`"
)),
}
}
}
/// Rate limiting configuration.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct RateLimitSettings {
/// Whether rate limiting is enabled
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
/// Maximum number of requests allowed in the time window (burst size)
#[serde(default = "default_burst_size")]
pub burst_size: u32,
/// Time window in seconds for rate limiting
#[serde(default = "default_per_seconds")]
pub per_seconds: u64,
}
impl Default for RateLimitSettings {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
burst_size: default_burst_size(),
per_seconds: default_per_seconds(),
}
}
}
const fn default_rate_limit_enabled() -> bool {
true
}
const fn default_burst_size() -> u32 {
100
}
const fn default_per_seconds() -> u64 {
60
}
/// Modbus TCP connection configuration.
///
/// Configures the connection parameters for communicating with the Modbus relay device
/// using Modbus RTU over TCP protocol.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct ModbusSettings {
/// IP address or hostname of the Modbus device
pub host: String,
/// TCP port for Modbus communication (standard Modbus TCP port is 502)
pub port: u16,
/// Modbus slave/device ID (unit identifier)
pub slave_id: u8,
/// Operation timeout in seconds
pub timeout_secs: u8,
}
impl Default for ModbusSettings {
fn default() -> Self {
Self {
host: "192.168.0.200".to_string(),
port: 502,
slave_id: 0,
timeout_secs: 5,
}
}
}
/// Relay control configuration.
///
/// Configures parameters for relay management and labeling.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct RelaySettings {
/// Maximum length for custom relay labels (in characters)
pub label_max_length: u8,
}
impl Default for RelaySettings {
fn default() -> Self {
Self {
label_max_length: 8,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_display_development() {
let env = Environment::Development;
assert_eq!(env.to_string(), "development");
}
#[test]
fn environment_display_production() {
let env = Environment::Production;
assert_eq!(env.to_string(), "production");
}
#[test]
fn environment_from_str_development() {
assert_eq!(
Environment::try_from("development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("dev").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("Development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("DEV").unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_str_production() {
assert_eq!(
Environment::try_from("production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("prod").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("Production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("PROD").unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_str_invalid() {
let result = Environment::try_from("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a supported environment"));
}
#[test]
fn environment_from_string_development() {
assert_eq!(
Environment::try_from("development".to_string()).unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_string_production() {
assert_eq!(
Environment::try_from("production".to_string()).unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_string_invalid() {
let result = Environment::try_from("invalid".to_string());
assert!(result.is_err());
}
#[test]
fn environment_default_is_development() {
let env = Environment::default();
assert_eq!(env, Environment::Development);
}
#[test]
fn rate_limit_settings_default() {
let settings = RateLimitSettings::default();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 100);
assert_eq!(settings.per_seconds, 60);
}
#[test]
fn rate_limit_settings_deserialize_full() {
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 50);
assert_eq!(settings.per_seconds, 30);
}
#[test]
fn rate_limit_settings_deserialize_partial() {
let json = r#"{"enabled": false}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(!settings.enabled);
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
#[test]
fn rate_limit_settings_deserialize_empty() {
let json = "{}";
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled); // default
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
// T009: Integration test for CorsSettings within Settings struct
#[test]
fn settings_loads_cors_section_from_yaml() {
// Create a temporary settings file with CORS configuration
@@ -126,6 +369,15 @@ cors:
- "http://localhost:5173"
allow_credentials: false
max_age_secs: 3600
modbus:
host: "192.168.0.200"
port: 502
slave_id: 0
timeout_secs: 5
relay:
label_max_length: 50
"#;
// Use serde_yaml to deserialize directly
-26
View File
@@ -1,26 +0,0 @@
/// Modbus TCP connection configuration.
///
/// Configures the connection parameters for communicating with the Modbus relay device
/// using Modbus RTU over TCP protocol.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct ModbusSettings {
/// IP address or hostname of the Modbus device
pub host: String,
/// TCP port for Modbus communication (standard Modbus TCP port is 502)
pub port: u16,
/// Modbus slave/device ID (unit identifier)
pub slave_id: u8,
/// Operation timeout in seconds
pub timeout_secs: u8,
}
impl Default for ModbusSettings {
fn default() -> Self {
Self {
host: "192.168.0.200".to_string(),
port: 502,
slave_id: 0,
timeout_secs: 5,
}
}
}
-75
View File
@@ -1,75 +0,0 @@
/// Rate limiting configuration.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct RateLimitSettings {
/// Whether rate limiting is enabled
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
/// Maximum number of requests allowed in the time window (burst size)
#[serde(default = "default_burst_size")]
pub burst_size: u32,
/// Time window in seconds for rate limiting
#[serde(default = "default_per_seconds")]
pub per_seconds: u64,
}
impl Default for RateLimitSettings {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
burst_size: default_burst_size(),
per_seconds: default_per_seconds(),
}
}
}
const fn default_rate_limit_enabled() -> bool {
true
}
const fn default_burst_size() -> u32 {
100
}
const fn default_per_seconds() -> u64 {
60
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limit_settings_default() {
let settings = RateLimitSettings::default();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 100);
assert_eq!(settings.per_seconds, 60);
}
#[test]
fn rate_limit_settings_deserialize_full() {
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 50);
assert_eq!(settings.per_seconds, 30);
}
#[test]
fn rate_limit_settings_deserialize_partial() {
let json = r#"{"enabled": false}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(!settings.enabled);
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
#[test]
fn rate_limit_settings_deserialize_empty() {
let json = "{}";
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled); // default
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
}
-16
View File
@@ -1,16 +0,0 @@
/// Relay control configuration.
///
/// Configures parameters for relay management and labeling.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct RelaySettings {
/// Maximum length for custom relay labels (in characters)
pub label_max_length: u8,
}
impl Default for RelaySettings {
fn default() -> Self {
Self {
label_max_length: 8,
}
}
}
+33 -109
View File
@@ -10,9 +10,6 @@ use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService;
use crate::infrastructure::modbus::factory::create_relay_controller;
use crate::infrastructure::persistence::factory::create_label_repository;
use crate::presentation::api::relay_api::RelayApi;
use crate::{
middleware::rate_limit::{RateLimit, RateLimitConfig},
route::Api,
@@ -97,17 +94,17 @@ impl From<Application> for RunnableApplication {
}
impl Application {
fn setup_app(settings: &Settings, relay_api: RelayApi) -> poem::Route {
fn setup_app(settings: &Settings) -> poem::Route {
let api_service = OpenApiService::new(
(Api::from(settings).apis(), relay_api),
Api::from(settings).apis(),
settings.application.clone().name,
settings.application.clone().version,
)
.url_prefix("/api");
let ui = api_service.swagger_ui();
poem::Route::new()
.nest("/api", api_service.clone())
.nest("/specs", api_service.spec_endpoint_yaml())
.nest("/api", api_service)
.nest("/", ui)
}
@@ -128,31 +125,22 @@ impl Application {
/// Builds a new application with the given settings and optional TCP listener.
///
/// If no listener is provided, one will be created based on the settings.
///
/// # Errors
///
/// Returns an error if dependency injection fails (currently always succeeds).
pub async fn build(
#[must_use]
pub fn build(
settings: Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Result<Self, Box<dyn std::error::Error>> {
let use_mock = cfg!(test) || std::env::var("CI").is_ok();
let relay_controller = create_relay_controller(&settings.modbus, use_mock).await;
let label_repository = create_label_repository(&settings.database.path, use_mock).await?;
let relay_api = RelayApi::new(relay_controller, label_repository);
) -> Self {
let port = settings.application.port;
let host = settings.application.clone().host;
let app = Self::setup_app(&settings, relay_api);
let app = Self::setup_app(&settings);
let server = Self::setup_server(&settings, tcp_listener);
Ok(Self {
Self {
server,
app,
host,
port,
settings,
})
}
}
/// Converts the application into a runnable application.
@@ -199,131 +187,67 @@ mod tests {
}
}
#[tokio::test]
async fn application_build_and_host() {
#[test]
fn application_build_and_host() {
let settings = create_test_settings();
let app = Application::build(settings.clone(), None).await.unwrap();
let app = Application::build(settings.clone(), None);
assert_eq!(app.host(), settings.application.host);
}
#[tokio::test]
async fn application_build_and_port() {
#[test]
fn application_build_and_port() {
let settings = create_test_settings();
let app = Application::build(settings, None).await.unwrap();
let app = Application::build(settings, None);
assert_eq!(app.port(), 8080);
}
#[tokio::test]
async fn application_host_returns_correct_value() {
#[test]
fn application_host_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None).await.unwrap();
let app = Application::build(settings, None);
assert_eq!(app.host(), "127.0.0.1");
}
#[tokio::test]
async fn application_port_returns_correct_value() {
#[test]
fn application_port_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None).await.unwrap();
let app = Application::build(settings, None);
assert_eq!(app.port(), 8080);
}
#[tokio::test]
async fn application_with_custom_listener() {
#[test]
fn application_with_custom_listener() {
let settings = create_test_settings();
let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let port = tcp_listener.local_addr().unwrap().port();
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
let app = Application::build(settings, Some(listener)).await.unwrap();
let app = Application::build(settings, Some(listener));
assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), 8080);
}
#[tokio::test]
async fn runnable_application_uses_cors_from_settings() {
// T015: Test that CORS middleware is configured from settings
#[test]
fn runnable_application_uses_cors_from_settings() {
// GIVEN: An application with custom CORS settings
let mut settings = create_test_settings();
settings.cors = crate::settings::CorsSettings {
allowed_origins: vec!["http://localhost:5173".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
let app = Application::build(settings, None).await.unwrap();
// WHEN: The application is converted to a runnable application
let app = Application::build(settings, None);
let _runnable_app = app.make_app();
// THEN: The middleware chain should use CORS settings from configuration
// Note: This is a structural test - actual CORS behavior is tested in integration tests (T016)
// The fact that this compiles and runs without panic verifies that:
// 1. CORS settings are properly loaded
// 2. The From<CorsSettings> trait is correctly implemented
// 3. The middleware chain accepts the CORS configuration
}
#[tokio::test]
async fn test_application_build_succeeds_in_test_mode() {
let settings = create_test_settings();
let app = Application::build(settings, None).await;
assert!(
app.is_ok(),
"Application::build() should succeed in test mode"
);
let app = app.unwrap();
assert_eq!(app.port(), 8080);
assert_eq!(app.host(), "127.0.0.1");
let runnable_app = app.make_app();
let _app: App = runnable_app.into();
// Success - the application was built with dependencies and can run
}
// ============================================================================
// T039d: RelayApi Registration Tests
// ============================================================================
// These tests verify that the RelayApi is properly registered in the route
// aggregator with correct OpenAPI tagging.
// T039d: Test 1 - OpenAPI spec includes /relays endpoints
#[tokio::test]
async fn test_openapi_spec_includes_relay_endpoints() {
let settings = create_test_settings();
let app: App = Application::build(settings, None)
.await
.unwrap()
.make_app()
.into();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/specs").send().await;
resp.assert_status_is_ok();
let spec = resp.0.into_body().into_string().await.unwrap();
assert!(
spec.contains("/relays:"),
"OpenAPI spec should include the /relays path, got:\n{spec}"
);
assert!(
spec.contains("/relays/{id}/toggle:"),
"OpenAPI spec should include the /relays/{{id}}/toggle path, got:\n{spec}"
);
}
// T039d: Test 2 - OpenAPI spec includes the Relays tag
#[tokio::test]
async fn test_swagger_ui_includes_relays_tag() {
let settings = create_test_settings();
let app: App = Application::build(settings, None)
.await
.unwrap()
.make_app()
.into();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/specs").send().await;
resp.assert_status_is_ok();
let spec = resp.0.into_body().into_string().await.unwrap();
assert!(
spec.contains("Relays"),
"OpenAPI spec should include a 'Relays' tag, got:\n{spec}"
);
}
}
-271
View File
@@ -1,271 +0,0 @@
//! Contract tests for the Relay API HTTP endpoints.
//!
//! - **T048**: `GET /api/relays` contract tests
//! - **T050**: `POST /api/relays/:id/toggle` contract tests
use std::sync::Arc;
use poem::{http::StatusCode, test::TestClient};
use poem_openapi::OpenApiService;
use sta::{
domain::relay::{
controller::RelayController,
repository::RelayLabelRepository,
types::{RelayId, RelayLabel, RelayState},
},
infrastructure::{
modbus::mock_controller::MockRelayController,
persistence::label_repository::MockRelayLabelRepository,
},
presentation::api::relay_api::RelayApi,
};
// -- Helpers --
fn build_test_client(
controller: Arc<MockRelayController>,
repo: Arc<MockRelayLabelRepository>,
) -> TestClient<impl poem::Endpoint> {
let relay_api = RelayApi::new(controller, repo);
let api_service = OpenApiService::new(relay_api, "STA", "0.1");
let app = poem::Route::new().nest("/api", api_service);
TestClient::new(app)
}
/// Creates a controller with all 8 relays initialised to `Off`.
async fn all_relays_off() -> Arc<MockRelayController> {
let controller = Arc::new(MockRelayController::new());
for id in 1u8..=8 {
controller
.write_relay_state(RelayId::new(id).unwrap(), RelayState::Off)
.await
.unwrap();
}
controller
}
// ===========================================================================
// T048: GET /api/relays
// ===========================================================================
/// T048 Returns 200 OK.
#[tokio::test]
async fn get_all_relays_returns_200() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
}
/// T048 Returns an array of exactly 8 `RelayDto` objects.
#[tokio::test]
async fn get_all_relays_returns_array_of_8_relay_dtos() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
resp.assert_status_is_ok();
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert_eq!(body.len(), 8, "Expected 8 relays, got {}", body.len());
}
/// T048 Relay IDs are 1 through 8, in ascending order.
#[tokio::test]
async fn get_all_relays_relay_ids_are_1_to_8_in_order() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
for (index, relay) in body.iter().enumerate() {
let expected_id = index + 1;
assert_eq!(
relay["id"], expected_id,
"Relay at index {index} should have id {expected_id}"
);
}
}
/// T048 Every relay has a `state` field that is either `"on"` or `"off"`.
#[tokio::test]
async fn get_all_relays_each_relay_has_valid_state_field() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
for relay in &body {
let state = relay["state"].as_str().expect("state should be a string");
assert!(
state == "on" || state == "off",
"state must be 'on' or 'off', got '{state}'"
);
}
}
/// T048 Every relay has a `label` field (string).
#[tokio::test]
async fn get_all_relays_each_relay_has_label_field() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
for relay in &body {
assert!(relay["label"].is_string(), "label should be a string field");
}
}
/// T048 Relay states in the response match the controller's actual states.
#[tokio::test]
async fn get_all_relays_states_reflect_controller_state() {
let controller = all_relays_off().await;
controller
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
.await
.unwrap();
controller
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
.await
.unwrap();
let cli = build_test_client(controller, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.get("/api/relays").send().await;
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert_eq!(body[0]["state"], "on", "Relay 1 should be on");
assert_eq!(body[1]["state"], "off", "Relay 2 should be off");
assert_eq!(body[2]["state"], "on", "Relay 3 should be on");
assert_eq!(body[3]["state"], "off", "Relay 4 should be off");
}
/// T048 A relay with a persisted label returns that label.
#[tokio::test]
async fn get_all_relays_relay_with_label_returns_label() {
let repo = Arc::new(MockRelayLabelRepository::new());
repo.save_label(
RelayId::new(2).unwrap(),
RelayLabel::new("Water Pump".to_string()).unwrap(),
)
.await
.unwrap();
let cli = build_test_client(all_relays_off().await, repo);
let resp = cli.get("/api/relays").send().await;
let body: Vec<serde_json::Value> = resp.json().await.value().deserialize();
assert_eq!(body[1]["label"], "Water Pump");
}
// ===========================================================================
// T050: POST /api/relays/:id/toggle
// ===========================================================================
/// T050 Returns 200 OK with a `RelayDto` body.
#[tokio::test]
async fn toggle_relay_returns_200_with_relay_dto() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert!(body["id"].is_number());
assert!(body["state"].is_string());
assert!(body["label"].is_string());
}
/// T050 Returns 404 for relay id 0 (below valid range).
#[tokio::test]
async fn toggle_relay_returns_404_for_id_below_range() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/0/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
/// T050 Returns 404 for relay id 9 (above valid range).
#[tokio::test]
async fn toggle_relay_returns_404_for_id_above_range() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/9/toggle").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
/// T050 State changes from `Off` to `On` and response reflects new state.
#[tokio::test]
async fn toggle_relay_off_to_on_response_shows_on() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/1/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["state"], "on");
}
/// T050 State changes from `On` to `Off` and response reflects new state.
#[tokio::test]
async fn toggle_relay_on_to_off_response_shows_off() {
let controller = Arc::new(MockRelayController::new());
controller
.write_relay_state(RelayId::new(5).unwrap(), RelayState::On)
.await
.unwrap();
let cli = build_test_client(controller, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/5/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["state"], "off");
}
/// T050 State actually changes in the underlying controller, not just in the response.
#[tokio::test]
async fn toggle_relay_state_actually_changes_in_controller() {
let controller = all_relays_off().await;
let relay_id = RelayId::new(3).unwrap();
let cli = build_test_client(controller.clone(), Arc::new(MockRelayLabelRepository::new()));
cli.post("/api/relays/3/toggle").send().await;
let state = controller.read_relay_state(relay_id).await.unwrap();
assert_eq!(state, RelayState::On, "Relay 3 should be On in the controller after toggle");
}
/// T050 Response includes the correct relay id.
#[tokio::test]
async fn toggle_relay_response_includes_correct_relay_id() {
let cli = build_test_client(all_relays_off().await, Arc::new(MockRelayLabelRepository::new()));
let resp = cli.post("/api/relays/4/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["id"], 4);
}
/// T050 Response includes a persisted label.
#[tokio::test]
async fn toggle_relay_response_includes_label_when_set() {
let repo = Arc::new(MockRelayLabelRepository::new());
repo.save_label(
RelayId::new(6).unwrap(),
RelayLabel::new("Heater".to_string()).unwrap(),
)
.await
.unwrap();
let cli = build_test_client(all_relays_off().await, repo);
let resp = cli.post("/api/relays/6/toggle").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.json().await.value().deserialize();
assert_eq!(body["label"], "Heater");
}
+10 -12
View File
@@ -13,7 +13,7 @@ use poem::test::TestClient;
use sta::{settings::Settings, startup::Application};
/// Helper function to create a test app with custom CORS settings.
async fn get_test_app_with_cors(
fn get_test_app_with_cors(
allowed_origins: Vec<String>,
allow_credentials: bool,
max_age_secs: i32,
@@ -32,8 +32,6 @@ async fn get_test_app_with_cors(
settings.cors.max_age_secs = max_age_secs;
Application::build(settings, Some(listener))
.await
.expect("Failed to build application")
.make_app()
.into()
}
@@ -44,7 +42,7 @@ async fn get_test_app_with_cors(
#[tokio::test]
async fn preflight_request_returns_cors_headers() {
// GIVEN: An app with CORS configured for specific origin
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent with Origin header
@@ -84,7 +82,7 @@ async fn preflight_request_returns_cors_headers() {
#[tokio::test]
async fn get_request_with_origin_returns_allow_origin_header() {
// GIVEN: An app with CORS configured for specific origin
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let client = TestClient::new(app);
// WHEN: A GET request is sent with Origin header
@@ -121,7 +119,7 @@ async fn preflight_response_includes_max_age_from_config() {
vec!["http://localhost:5173".to_string()],
false,
custom_max_age,
).await;
);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -155,7 +153,7 @@ async fn response_includes_allow_credentials_when_configured() {
vec!["http://localhost:5173".to_string()],
true, // allow_credentials
3600,
).await;
);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -189,7 +187,7 @@ async fn response_does_not_include_credentials_when_disabled() {
vec!["http://localhost:5173".to_string()],
false, // allow_credentials
3600,
).await;
);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -219,7 +217,7 @@ async fn response_does_not_include_credentials_when_disabled() {
#[tokio::test]
async fn preflight_response_includes_correct_allowed_methods() {
// GIVEN: An app with CORS configured
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent
@@ -262,7 +260,7 @@ async fn wildcard_origin_works_with_credentials_disabled() {
vec!["*".to_string()],
false, // credentials MUST be false with wildcard
3600,
).await;
);
let client = TestClient::new(app);
// WHEN: A preflight OPTIONS request is sent with any origin
@@ -301,7 +299,7 @@ async fn multiple_origins_are_supported() {
],
false,
3600,
).await;
);
let client = TestClient::new(app);
// WHEN: A request is sent with the first origin
@@ -343,7 +341,7 @@ async fn multiple_origins_are_supported() {
#[tokio::test]
async fn unauthorized_origin_is_rejected() {
// GIVEN: An app with CORS configured for specific origins only
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600).await;
let app = get_test_app_with_cors(vec!["http://localhost:5173".to_string()], false, 3600);
let client = TestClient::new(app);
// WHEN: A request is sent with an unauthorized origin
@@ -427,10 +427,7 @@ async fn test_repository_error_handling() {
// Test with invalid relay ID (should be caught by domain validation)
let invalid_relay_id = RelayId::new(9); // This will fail validation
assert!(
invalid_relay_id.is_err(),
"Invalid relay ID should fail validation"
);
assert!(invalid_relay_id.is_err(), "Invalid relay ID should fail validation");
// Test with invalid label (should be caught by domain validation)
let invalid_label = RelayLabel::new("".to_string()); // Empty label
+24 -27
View File
@@ -1,14 +1,13 @@
# Documentation Update Summary
# Documentation Update Summary - T010
**Task**: T010 Add CorsSettings struct to settings.rs (Phase 0.5)
**Subsequent Tasks**: T011T016 (CORS fully implemented)
**Phase 4 (US1)**: Complete — Monitor & Toggle Relay States
**Date**: 2026-05-15 (updated from 2026-01-03)
**Task**: T010 - Add CorsSettings struct to settings.rs
**Phase**: 0.5 - CORS Configuration & Production Security
**Date**: 2026-01-03
**Documentation Author**: Claude Code (AI Assistant)
## Overview
This document summarizes the documentation updates completed for the CORS configuration feature (Phase 0.5, Tasks T009T016) and the subsequent US1 MVP implementation (Phases 24). All CORS tasks are complete, and the US1 feature (view and toggle relay states via web UI) is now operational.
This document summarizes the documentation updates completed for task T010, which implemented the `CorsSettings` configuration structure as part of the CORS configuration feature (Phase 0.5).
## Files Updated
@@ -219,21 +218,22 @@ pub struct CorsSettings {
- Poem CORS Middleware documentation
- CORS Specification (W3C)
## Task Status
## Next Steps Documented
**CORS Configuration (Phase 0.5)** — All tasks complete:
**Remaining Tasks Clearly Outlined**:
- ✅ T009: Tests written (documented)
- ✅ T010: Struct implemented (documented)
- T011: development.yaml updated
- T012: production.yaml created
- T013T014: `From<CorsSettings> for Cors` trait implemented
- T015: Cors::new() replaced in startup chain
- T016: 9 integration tests for CORS headers
- 🚧 T011: Update development.yaml
- 🚧 T012: Create production.yaml
- 🚧 T013-T014: Implement build_cors() function
- 🚧 T015: Replace Cors::new() in middleware chain
- 🚧 T016: Integration tests for CORS headers
**US1 — Monitor & Toggle Relay States (Phases 24)** — Complete:
- ✅ Phase 2: Domain layer types (RelayId, RelayState, RelayLabel, etc.)
- ✅ Phase 3: Infrastructure (Modbus controllers, SQLite persistence, factories)
- ✅ Phase 4: Application use cases, API endpoints, Vue 3 frontend with polling
**Each Task Includes**:
- What needs to be done
- Which file to modify
- Example code snippets
- Expected behavior
## Documentation Quality Metrics
@@ -316,19 +316,16 @@ pub struct CorsSettings {
| 2026-01-03 | Documentation | Comprehensive CORS guide created |
| 2026-01-03 | Documentation | README updated with CORS section |
| 2026-01-03 | Documentation | This summary document created |
| 2026-01-22 | T013T016 | CORS middleware and integration tests completed |
| 2026-05-15 | US1 (Phases 24) | Domain, infrastructure, application, presentation, frontend |
| 2026-05-15 | Documentation | All docs updated for US1 completion |
## Conclusion
The documentation for the project is **up to date** and covers both Phase 0.5 (CORS) and Phase 24 (US1 MVP). Key accomplishments:
The documentation for T010 (CorsSettings struct implementation) is **complete and comprehensive**. It covers:
1. **CORS Configuration**: Complete from research through implementation and integration tests
2. **Domain Layer**: Type-driven design with 100% test coverage
3. **Infrastructure**: Modbus TCP client, mock controller, SQLite persistence with factory wiring
4. **Application**: Use cases for listing and toggling relays with health monitoring
5. **Presentation**: REST API with OpenAPI docs, plus Vue 3 frontend with real-time polling
1. **Configuration**: How to configure CORS for development and production
2. **Security**: Critical security constraints and best practices
3. **Testing**: All 5 TDD tests explained with purpose
4. **Troubleshooting**: Common issues and solutions
5. **Next Steps**: Clear roadmap for remaining CORS tasks
The documentation follows project standards:
- **TDD/TyDD Approach**: Tests documented before implementation
@@ -336,4 +333,4 @@ The documentation follows project standards:
- **Specification-Driven**: Links to research and task specifications
- **Maintainability**: Clear structure, cross-references, and changelog
**Status**: All implemented features are fully documented and ready for use.
**Status**: Ready for review and use by developers, DevOps, and future maintainers.
+55 -46
View File
@@ -1,8 +1,8 @@
# CORS Configuration Guide
**Last Updated**: 2026-01-23
**Related Tasks**: T009-T016
**Status**: Complete (Phase 0.5)
**Last Updated**: 2026-01-03
**Related Tasks**: T009 (Tests), T010 (Implementation)
**Status**: Implemented (Phase 0.5)
## Overview
@@ -44,7 +44,7 @@ Relay Device (local network)
### CorsSettings Struct
Located in `backend/src/settings/cors.rs`:
Located in `backend/src/settings.rs` (lines 217-232):
```rust
#[derive(Debug, serde::Deserialize, Clone)]
@@ -76,7 +76,7 @@ The implementation uses a **hybrid approach** (Option C from research):
- `allow_credentials`: Whether to allow cookies/auth headers
- `max_age_secs`: How long browsers cache preflight responses
**Hardcoded in Implementation**:
**Hardcoded in Implementation** (will be in T014):
- **Methods**: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` (API-specific)
- **Headers**: `content-type`, `authorization` (minimum for API)
@@ -109,7 +109,7 @@ frontend_url: http://localhost:5173 # Vite default port
### Production Environment
**File**: `backend/settings/production.yaml`
**File**: `backend/settings/production.yaml` (to be created in T012)
```yaml
cors:
@@ -129,7 +129,23 @@ frontend_url: "https://sta.example.com"
### Integration with Settings System
The `CorsSettings` struct is part of the settings module. Settings are loaded with `#[serde(default)]` to ensure backward compatibility: if the `cors` section is missing from YAML, it uses the restrictive `Default` implementation.
The `CorsSettings` struct is integrated into the main `Settings` struct (line 30):
```rust
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Settings {
pub application: ApplicationSettings,
pub debug: bool,
pub frontend_url: String,
pub rate_limit: RateLimitSettings,
pub modbus: ModbusSettings,
pub relay: RelaySettings,
#[serde(default)] // Uses Default::default() if missing
pub cors: CorsSettings,
}
```
The `#[serde(default)]` attribute ensures backward compatibility: if the `cors` section is missing from YAML, it uses the restrictive `Default` implementation.
### Loading and Precedence
@@ -302,7 +318,7 @@ cargo test -p sta cors -- --nocapture
**Browser Security Policy**: When `allow_credentials: true`, wildcard origins (`*`) are **forbidden** by the CORS specification.
**Enforcement**: The `From<CorsSettings> for Cors` implementation panics during startup if this constraint is violated:
**Enforcement**: The upcoming `build_cors()` function (T014) will panic during startup if this constraint is violated:
```rust
if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) {
@@ -411,9 +427,11 @@ cors:
### Preflight Requests Failing (OPTIONS)
**Cause**: Backend not allowing OPTIONS method.
**Cause**: Backend not allowing OPTIONS method (will be fixed in T014).
**Solution**: The `From<CorsSettings> for Cors` trait implementation hardcodes OPTIONS in the allowed methods:
**Temporary Workaround**: None - wait for T014 implementation.
**Permanent Solution**: The upcoming `build_cors()` function will hardcode:
```rust
cors.allow_methods(vec![
Method::GET, Method::POST, Method::PUT,
@@ -436,13 +454,13 @@ cors.allow_methods(vec![
### Headers Not Allowed
**Cause**: Custom headers not in allowed list.
**Cause**: Custom headers not in allowed list (will be in T014).
**Current Allowed Headers**:
**Current Allowed Headers** (to be implemented):
- `content-type` (for JSON request bodies)
- `authorization` (for Authelia authentication tokens)
**Adding Custom Headers**: Requires modifying the `From<CorsSettings> for Cors` trait implementation.
**Adding Custom Headers**: Requires modifying `build_cors()` function (T014).
## Dependencies
@@ -463,43 +481,37 @@ serde_yaml = "0.9.34"
| File | Purpose |
|------|---------|
| `backend/src/settings/cors.rs` | `CorsSettings` struct definition |
| `backend/settings/base.yaml` | Baseline configuration |
| `backend/src/settings.rs` | `CorsSettings` struct definition |
| `backend/settings/base.yaml` | Baseline configuration (no CORS section yet) |
| `backend/settings/development.yaml` | Development CORS (permissive) |
| `backend/settings/production.yaml` | Production CORS (restrictive) |
| `backend/settings/production.yaml` | Production CORS (restrictive) - to be created in T012 |
## Completed Tasks
## Next Steps (Remaining Tasks)
All CORS configuration tasks (T009-T016) have been implemented and tested:
### T011: Update development.yaml
- Add `cors:` section with permissive settings
- Update `frontend_url` to `http://localhost:5173` (Vite default)
### T009-T010: CorsSettings Struct (Phase 0.5)
- 5 unit tests written (TDD approach) and the `CorsSettings` struct implemented with fail-safe defaults
- Located in `backend/src/settings/cors.rs`
### T012: Create production.yaml
- Add `cors:` section with restrictive settings
- Use `https://sta.example.com` as allowed origin
- Set `allow_credentials: true` for Authelia
### T011: Development YAML Configuration
- Added `cors:` section with wildcard origin and `allow_credentials: false`
- Updated `frontend_url` to `http://localhost:5173` (Vite default)
- File: `backend/settings/development.yaml`
### T013-T014: Implement build_cors() Function
- Create `build_cors(settings: &CorsSettings) -> Cors` in `startup.rs`
- Validate wildcard + credentials constraint
- Hardcode methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
- Hardcode headers (content-type, authorization)
- Add structured logging
### T012: Production YAML Configuration
- Added `cors:` section with specific origin and `allow_credentials: true`
- File: `backend/settings/production.yaml`
### T013-T014: Cors Middleware Implementation
- 6 unit tests written for the `From<CorsSettings> for Cors` trait
- Implemented the conversion trait in `backend/src/settings/cors.rs`
- Validates wildcard + credentials constraint (panics on misconfiguration)
- Hardcodes methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
- Hardcodes headers (content-type, authorization)
- Adds structured logging
### T015: Middleware Chain Integration
- Replaced `Cors::new()` with `Cors::from(settings.cors)` in startup.rs
- CORS applied after rate limiting (order: RateLimit → CORS → Data)
### T015: Replace Cors::new() in Middleware Chain
- Update `startup.rs` line ~86
- Call `build_cors(&value.settings.cors)`
### T016: Integration Tests
- 9 comprehensive integration tests in `backend/tests/cors_test.rs`
- Covers: preflight requests, actual request headers, max-age, credentials, methods, wildcard, multiple origins, unauthorized origin rejection
- Write tests verifying CORS headers in HTTP responses
- Test OPTIONS preflight requests
- Verify `Access-Control-Allow-Origin` header
## References
@@ -521,10 +533,7 @@ All CORS configuration tasks (T009-T016) have been implemented and tested:
| 2026-01-03 | T009 | Test suite written (5 tests, TDD approach) |
| 2026-01-03 | T010 | `CorsSettings` struct implemented with defaults |
| 2026-01-03 | Documentation | This guide created |
| 2026-01-22 | T013-T014 | `From<CorsSettings> for Cors` trait implemented |
| 2026-01-22 | T015 | CORS middleware integrated into startup chain |
| 2026-01-22 | T016 | 9 integration tests written and passing |
---
**Maintainer Notes**: This configuration follows the project's **Type-Driven Development (TyDD)** and **Test-Driven Development (TDD)** principles. Tests were written first (T009, T013), then implementations were created to pass those tests. The CORS feature is fully implemented and tested across all environments.
**Maintainer Notes**: This configuration follows the project's **Type-Driven Development (TyDD)** and **Test-Driven Development (TDD)** principles. Tests were written first (T009), then the implementation (T010) was created to pass those tests. The upcoming `build_cors()` function (T014) will complete the CORS feature by applying these settings to the Poem middleware chain.
+15 -17
View File
@@ -2,8 +2,8 @@
**Feature**: 001-modbus-relay-control
**Phase**: 2 (Domain Layer - Type-Driven Development)
**Status**: Complete (US1 MVP also complete)
**Last Updated**: 2026-05-15
**Status**: Complete
**Last Updated**: 2026-01-04
## Overview
@@ -419,15 +419,13 @@ backend/src/domain/
├── modbus.rs # ModbusAddress type
└── relay/
├── mod.rs # Relay module exports
├── controller.rs # RelayController trait (trait definition)
├── controler.rs # RelayController trait (trait definition)
├── entity.rs # Relay aggregate
── types/
├── mod.rs # Type exports
├── relayid.rs # RelayId newtype
├── relaystate.rs # RelayState enum
└── relaylabel.rs # RelayLabel newtype
└── repository/
└── label.rs # RelayLabelRepository trait
── types/
├── mod.rs # Type exports
├── relayid.rs # RelayId newtype
├── relaystate.rs # RelayState enum
└── relaylabel.rs # RelayLabel newtype
```
## Dependency Graph
@@ -560,16 +558,16 @@ Coverage: 100% for domain layer
## Next Steps
**Phase 4 (US1 MVP) — Complete** — Users can view all 8 relay states and toggle individual relays on/off via the web UI.
**Phase 3: Infrastructure Layer** (Tasks T028-T040)
The infrastructure, application, and presentation layers were built on top of these domain types:
Now that domain types are complete, the infrastructure layer can:
1. **Infrastructure** (Phase 3): `ModbusRelayController` (real Modbus TCP client) + `MockRelayController` (testing), `SqliteRelayLabelRepository` for persistence, with factory functions for dependency injection
2. **Application** (Phase 3): `ToggleRelayUseCase`, `GetAllRelaysUseCase`, `HealthMonitor` service
3. **Presentation** (Phase 4): `RelayApi` handlers with `RelayDto`, REST endpoints (`GET /api/relays`, `POST /api/relays/{id}/toggle`)
4. **Frontend** (Phase 4): Vue 3 + TypeScript with `RelayCard`, `RelayGrid`, `useRelayPolling` composable (2s polling)
1. Implement `RelayController` trait with real Modbus client
2. Create `MockRelayController` for testing
3. Implement `RelayLabelRepository` with SQLite
4. Use domain types throughout infrastructure code
**Upcoming phases**: US2 (bulk controls), US3 (health monitoring UI), US4 (relay labeling)
**Key advantage**: Infrastructure layer can depend on stable, well-tested domain types with strong guarantees.
## References
Generated
+209 -12
View File
@@ -23,6 +23,76 @@
"type": "github"
}
},
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760971495,
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
"owner": "cachix",
"repo": "cachix",
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1766843567,
"narHash": "sha256-062oL6KZCH7ePf4BBG61OdFJUh5ovw6zTpd/lVwy/xk=",
"owner": "cachix",
"repo": "devenv",
"rev": "d0f2c8545f09e5aba9d321079a284b550371879d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devenv-root": {
"flake": false,
"locked": {
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
"type": "file",
"url": "file:///dev/null"
},
"original": {
"type": "file",
"url": "file:///dev/null"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
@@ -45,6 +115,43 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@@ -79,25 +186,115 @@
"type": "github"
}
},
"nixpkgs": {
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"lastModified": 1760663237,
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1761648602,
"narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
"owner": "cachix",
"repo": "nix",
"rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.30.6",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1764580874,
"narHash": "sha256-GMlWyeVh6fVuPeJI+ZmbJVV8DDS5wfdfDY88FHt5g/8=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"alejandra": "alejandra",
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
@@ -127,11 +324,11 @@
]
},
"locked": {
"lastModified": 1777950921,
"narHash": "sha256-NpOgt8ISaHTDNJZjNUfwFfbieKfRXzab4WKM31gZCGA=",
"lastModified": 1766803264,
"narHash": "sha256-eGK6He8BR6L7N73kyyjz/vGxZX1Usnr8Gwfs3D18KgE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "366ea19e0e55b768f74b7a0b2a20f847e7ae828d",
"rev": "6b5c52313aaf3f3e1a0a6757bb89846edfb5195c",
"type": "github"
},
"original": {
+19 -31
View File
@@ -1,69 +1,57 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
flake-utils.url = "github:numtide/flake-utils";
alejandra = {
url = "github:kamadorueda/alejandra/4.0.0";
inputs.nixpkgs.follows = "nixpkgs";
};
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
devenv-root = {
url = "file+file:///dev/null";
flake = false;
};
};
nixConfig = {
extra-trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
"phundrak.cachix.org-1:osJAkYO0ioTOPqaQCIXMfIRz1/+YYlVFkup3R2KSexk="
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
];
extra-substituters = [
"https://phundrak.cachix.org?priority=10"
"https://nix-community.cachix.org?priority=20"
"https://cache.nixos.org?priority=30"
"https://devenv.cachix.org"
];
};
outputs = {
self,
nixpkgs,
flake-utils,
rust-overlay,
alejandra,
...
}:
} @ inputs:
flake-utils.lib.eachDefaultSystem (
system: let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;};
rustVersion = pkgs.rust-bin.stable.latest.default;
targets = {
linux-x86_64 = {
crossPkgs = pkgs;
triple = "x86_64-unknown-linux-gnu";
};
linux-aarch64 = {
crossPkgs = pkgs.pkgsCross.aarch64-multiplatform;
triple = "aarch64-unknown-linux-gnu";
};
};
mkRustBuild = import ./nix/backend.nix;
packages = {
linux-x86_64 = mkRustBuild targets.linux-x86_64;
linux-aarch64 = mkRustBuild targets.linux-aarch64;
};
defaultBySystem = {
"x86_64-linux" = packages.linux-x86_64;
"aarch64-linux" = packages.linux-aarch64;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
in {
formatter = alejandra.defaultPackage.${system};
packages.backend =
packages
// {
default = defaultBySystem.${system} or packages.linux-x86_64;
packages = import ./nix/package.nix {inherit pkgs rustPlatform;};
devShell = import ./nix/shell.nix {
inherit inputs pkgs self rustVersion system;
};
devShell = import ./nix/shell.nix {inherit pkgs rustVersion;};
}
);
}
-17
View File
@@ -1,17 +0,0 @@
default: run
run:
pnpm run dev
build:
pnpm run build
preview:
pnpm run preview
sync:
pnpm run "generate:api"
## Local Variables:
## mode: makefile
## End:
+3 -3
View File
@@ -2,11 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STA</title>
<title>frontend</title>
</head>
<body class="bg-background text-text font-body">
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+47 -2
View File
@@ -1,5 +1,50 @@
mod backend
mod frontend
default: run
run:
cargo run
run-release:
cargo run --release
format:
cargo fmt --all
format-check:
cargo fmt --check --all
audit:
cargo deny check
build:
cargo build
build-release:
cargo build --release
lint:
cargo clippy --all-targets
release-build:
cargo build --release
release-run:
cargo run --release
test:
cargo test --all --all-targets
test-hardware:
cargo test --all --all-targets -- --ignored
coverage:
mkdir -p coverage
cargo tarpaulin --config backend/.tarpaulin.local.toml
coverage-ci:
mkdir -p coverage
cargo tarpaulin --config backend/.tarpaulin.ci.toml
check-all: format-check lint coverage audit
## Local Variables:
## mode: makefile
-24
View File
@@ -1,24 +0,0 @@
target: let
cargoToml = fromTOML (builtins.readFile ../backend/Cargo.toml);
inherit (cargoToml.package) name version;
pkgs = target.crossPkgs;
buildArgs = {
pname = name;
inherit version;
src = pkgs.lib.cleanSource ../.;
cargoLock.lockFile = ../Cargo.lock;
useNextest = true;
meta = {
inherit (cargoToml.package) description homepage;
};
postBuild = "${pkgs.upx}/bin/upx target/*/release/*${name}";
};
rustVersion = pkgs.rust-bin.stable.latest.default.override {
targets = [target.triple];
};
rustPlatform = target.crossPkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
in
rustPlatform.buildRustPackage buildArgs
+21
View File
@@ -0,0 +1,21 @@
{
pkgs,
rustPlatform,
...
}: let
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
name = cargoToml.package.name;
version = cargoToml.package.version;
rustBuild = rustPlatform.buildRustPackage {
pname = name;
inherit version;
src = ../.;
cargoLock.lockFile = ../Cargo.lock;
};
settingsDir = pkgs.runCommand "settings" {} ''
mkdir -p $out/settings
cp ${../settings}/*.yaml $out/settings/
'';
in {
jj-mcp = rustBuild;
}
+11
View File
@@ -0,0 +1,11 @@
{
rust-overlay,
inputs,
system,
...
}: let
overlays = [(import rust-overlay)];
in rec {
pkgs = import inputs.nixpkgs {inherit system overlays;};
version = pkgs.rust-bin.stable.latest.default;
}
+30 -4
View File
@@ -1,9 +1,17 @@
{
inputs,
pkgs,
self,
rustVersion,
system,
...
}:
pkgs.mkShell {
inputs.devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
packages = with pkgs; [
# Backend
(rustVersion.override {
extensions = [
"clippy"
@@ -25,8 +33,26 @@ pkgs.mkShell {
# Frontend
nodejs_24
rustywind # tailwind
prettier
eslint
pnpm
nodePackages.prettier
nodePackages.eslint
nodePackages.pnpm
];
processes.run.exec = "bacon run";
enterShell = ''
echo "🦀 Rust MCP development environment loaded!"
echo "📦 Rust version: $(rustc --version)"
echo "📦 Cargo version: $(cargo --version)"
echo ""
echo "Available tools:"
echo " - rust-analyzer (LSP)"
echo " - clippy (linter)"
echo " - rustfmt (formatter)"
echo " - bacon (continuous testing/linting)"
echo " - cargo-deny (dependency checker)"
echo " - cargo-tarpaulin (code coverage)"
'';
}
];
}
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from 'oxfmt';
export default defineConfig({
ignorePatterns: ['.direnv/**/*', '.gitea/**/*', 'backend/**/*', '**/*.toml', '**/*.md', '.sqlx/**/*'],
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: true,
trailingComma: 'es5',
sortTailwindcss: true,
sortPackageJson: true,
allowParens: 'always',
jsdoc: true,
sortImports: true,
vueIndentScriptAndStyle: false,
});
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'oxlint';
export default defineConfig({
plugins: ['typescript', 'unicorn', 'oxc', 'vue'],
categories: {
correctness: 'error',
},
rules: {},
env: {
builtin: true,
},
ignorePatterns: ['.direnv/**/*'],
});
+9 -23
View File
@@ -1,39 +1,25 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"generate:api": "curl -s http://localhost:3100/specs > openapi.yaml && openapi-typescript openapi.yaml -o src/api/schema.ts && rm openapi.yaml",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
"generate:api": "curl -s http://localhost:3100/specs > openapi.yaml && openapi-typescript openapi.yaml -o src/api/schema.ts && rm openapi.yaml"
},
"dependencies": {
"@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.0",
"openapi-fetch": "^0.15.2",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"tailwindcss": "^4.3.0",
"vue": "^3.5.34"
"openapi-fetch": "^0.15.0",
"vue": "^3.5.24"
},
"devDependencies": {
"@types/node": "^24.12.4",
"@vitejs/plugin-vue": "^6.0.6",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"less": "^4.6.4",
"less-loader": "^12.3.2",
"openapi-typescript": "^7.13.0",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"openapi-typescript": "^7.10.1",
"typescript": "~5.9.3",
"vite": "^7.3.3",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.2.9"
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}
+393 -2166
View File
File diff suppressed because it is too large Load Diff
+55 -142
View File
@@ -1,7 +1,6 @@
#+title: Implementation Tasks: Modbus Relay Control System
#+author: Lucien Cartier-Tilet
#+email: lucien@phundrak.com
#+startup: content align hideblocks
#+options: ^:nil
#+LATEX_CLASS_OPTIONS: [a4paper,10pt]
#+LATEX_HEADER: \makeatletter \@ifpackageloaded{geometry}{\geometry{margin=2cm}}{\usepackage[margin=2cm]{geometry}} \makeatother
@@ -28,7 +27,7 @@
--------------
* TODO Development phases [5/9]
* TODO Development phases [4/9]
** DONE Phase 1: Setup & Foundation (0.5 days) [8/8]
*Purpose*: Initialize project dependencies and directory structure
@@ -587,9 +586,7 @@ CLOSED: [2026-01-22 jeu. 00:02]
--------------
** DONE Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [5/5]
CLOSED: [2026-05-15 ven. 03:59]
- State "DONE" from "STARTED" [2026-05-15 ven. 03:59]
** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [1/5]
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
*Goal*: View current state of all 8 relays + toggle individual relay on/off
@@ -619,108 +616,27 @@ CLOSED: [2026-01-23 ven. 20:42]
- *File*: =src/application/use_cases/get_all_relays.rs=
- *Complexity*: Low | *Uncertainty*: Low
*** DONE Presentation Layer (Backend API) [3/3]
CLOSED: [2026-05-14 jeu. 18:43]
- State "DONE" from "TODO" [2026-05-14 jeu. 18:43]
*** STARTED Presentation Layer (Backend API) [0/2]
- State "STARTED" from "TODO" [2026-01-23 ven. 20:42]
- [X] *T045* [US1] [TDD] Define =RelayDto= in presentation layer
- [ ] *T045* [US1] [TDD] Define =RelayDto= in presentation layer
- Fields: =id= (=u8=), =state= ("on"/"off"), =label= (=Option=)
- Implement =From= for =RelayDto=
- *File*: =src/presentation/dto/relay_dto.rs=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T046* [US1] [TDD] Define API error responses
- [ ] *T046* [US1] [TDD] Define API error responses
- =ApiError= enum with status codes and messages
- Implement =poem::error::ResponseError=
- *File*: =src/presentation/error.rs=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T047* [US1] [TDD] Create =RelayApi= struct with dependency injection
- Create =RelayApi= struct that holds dependencies:
- =relay_controller: Arc<dyn RelayController>=
- =label_repository: Arc<dyn RelayLabelRepository>=
- Implement constructor: =RelayApi::new(controller, repository) -> Self=
- Add =#[derive(Clone)]= to allow sharing across poem-openapi
- *File*: =src/presentation/api/relay_api.rs= or =src/route/relay.rs=
- *Complexity*: Low | *Uncertainty*: Low
*TDD Checklist*:
- [ ] Test: =RelayApi::new()= creates instance with provided dependencies
- [ ] Test: =RelayApi= can be cloned (required for poem-openapi)
- [ ] Test: Constructor stores both controller and repository
*Pseudocode*:
#+begin_src rust
use std::sync::Arc;
use crate::domain::relay::{
controller::RelayController,
repository::RelayLabelRepository,
};
/// API handler for relay control endpoints.
///
/// This struct holds the dependencies needed for relay operations
/// and implements the poem-openapi handlers.
#[derive(Clone)]
pub struct RelayApi {
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
}
impl RelayApi {
/// Creates a new RelayApi with the provided dependencies.
///
/// # Arguments
///
/// * `relay_controller` - Controller for reading/writing relay states
/// * `label_repository` - Repository for managing relay labels
pub fn new(
relay_controller: Arc<dyn RelayController>,
label_repository: Arc<dyn RelayLabelRepository>,
) -> Self {
Self {
relay_controller,
label_repository,
}
}
}6 lerolero 7
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::modbus::MockRelayController;
use crate::infrastructure::persistence::MockLabelRepository;
#[test]
fn test_relay_api_new_creates_instance() {
// GIVEN: Mock dependencies
let controller = Arc::new(MockRelayController::new());
let repository = Arc::new(MockLabelRepository::new());
// WHEN: Creating RelayApi
let api = RelayApi::new(controller.clone(), repository.clone());
// THEN: Instance is created successfully
// Verify by checking that we can clone it (required for poem-openapi)
let _cloned_api = api.clone();
}
}
#+end_src
*Note*: After this task, T048-T051 will add endpoint methods to this struct.
--------------
*** DONE T039: Dependency Injection Setup (DECOMPOSED) [8/8]
CLOSED: [2026-05-14 jeu. 20:09]
- State "DONE" from "STARTED" [2026-05-14 jeu. 20:09]
- State "STARTED" from "TODO" [2026-03-06 ven. 22:11]
*** TODO T039: Dependency Injection Setup (DECOMPOSED) [0/8]
- Complexity :: High → Broken into 4 sub-tasks
- Uncertainty :: Medium
- Rationale :: Graceful degradation (FR-023), conditional mock/real controller
- Prerequisites :: T047 (RelayApi struct) must be complete before T039c
- [X] *T039a* [US1] [TDD] Create =ModbusRelayController= factory with retry and fallback
- [ ] *T039a* [US1] [TDD] Create =ModbusRelayController= factory with retry and fallback
- Factory function: ~create_relay_controller(settings, use_mock) => Arc~
- Retry 3 times with 2s backoff on connection failure
@@ -776,12 +692,13 @@ CLOSED: [2026-05-14 jeu. 20:09]
*TDD Checklist*:
- [X] Test: ~use_mock=true~ returns =MockRelayController= immediately
- [X] Test: Successful connection returns =ModbusRelayController=
- [X] Test: Connection failure after 3 retries returns =MockRelayController=
- [X] Test: Retry delays are 2 seconds between attempts
- [X] Test: Logs appropriate messages for each connection attempt
- [X] *T039b* [US4] [TDD] Create =RelayLabelRepository= factory
- [ ] Test: use_mock=true returns =MockRelayController= immediately
- [ ] Test: Successful connection returns =ModbusRelayController=
- [ ] Test: Connection failure after 3 retries returns =MockRelayController=
- [ ] Test: Retry delays are 2 seconds between attempts
- [ ] Test: Logs appropriate messages for each connection attempt
- [ ] *T039b* [US4] [TDD] Create =RelayLabelRepositor=y factory
- Factory function: ~create_label_repository(db_path, use_mock) => Arc~
- If use_mock: return =MockLabelRepository=
@@ -808,19 +725,17 @@ CLOSED: [2026-05-14 jeu. 20:09]
*TDD Checklist*:
- [X] Test: use_mock=true returns =MockLabelRepository=
- [X] Test: use_mock=false returns =SQLiteLabelRepository=
- [X] Test: Invalid =db_path= returns =RepositoryError=
- [X] *T039c* [US1] [TDD] Wire dependencies in =Application::build()=
- [ ] Test: use_mock=true returns =MockLabelRepository=
- [ ] Test: use_mock=false returns =SQLiteLabelRepository=
- [ ] Test: Invalid =db_path= returns =RepositoryError=
- [ ] *T039c* [US1] [TDD] Wire dependencies in =Application::build()=
- *Prerequisites*: T047 must be complete (RelayApi struct created)
- Determine test mode: ~cfg!(test) || env::var("CI").is_ok()~
- Call =create_relay_controller()= and =create_label_repository()=
- Create =RelayApi= instance with dependencies (requires T047)
- Pass =RelayApi= to OpenAPI service
- Pass dependencies to =RelayApi::new()=
- *File*: =src/startup.rs=
- *Complexity*: Medium | *Uncertainty*: Low
- *Note*: Tests for T039c have been written (they currently pass trivially)
*Pseudocode*:
@@ -855,10 +770,12 @@ CLOSED: [2026-05-14 jeu. 20:09]
*TDD Checklist*:
- [X] Test: =Application::build()= succeeds in test mode
- [X] Test: =Application::build()= creates correct mock dependencies when CI=true
- [X] Test: =Application::build()= creates real dependencies when not in test mode
- [X] *T039d* [US1] [TDD] Register =RelayApi= in route aggregator
- [ ] Test: =Application::build()= succeeds in test mode
- [ ] Test: =Application::build()= creates correct mock dependencies when CI=true
- [ ] Test: =Application::build()= creates real dependencies when not in test mode
- [ ] *T039d* [US1] [TDD] Register =RelayApi= in route aggregator
- Add =RelayApi= to OpenAPI service
- Tag: "Relays"
- *File*: =src/startup.rs=
@@ -866,41 +783,39 @@ CLOSED: [2026-05-14 jeu. 20:09]
*TDD Checklist*:
- [X] Test: OpenAPI spec includes =/api/relays= endpoints
- [X] Test: Swagger UI renders =Relays= tag
- [ ] Test: OpenAPI spec includes =/api/relays= endpoints
- [ ] Test: Swagger UI renders =Relays= tag
--------------
- [X] *T048* [US1] [TDD] Write contract tests for =GET /api/relays=
- [ ] *T048* [US1] [TDD] Write contract tests for =GET /api/relays=
- Test: Returns 200 with array of 8 =RelayDto=
- Test: Each relay has id 1-8, state, and optional label
- *File*: =tests/contract/test_relay_api.rs=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T049* [US1] [TDD] Implement =GET /api/relays= endpoint
- [ ] *T049* [US1] [TDD] Implement =GET /api/relays= endpoint
- ~#[oai(path = "/relays", method = "get")]~
- Call =GetAllRelaysUseCase=, map to =RelayDto=
- *File*: =src/presentation/api/relay_api.rs=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T050* [US1] [TDD] Write contract tests for =POST /api/relays/{id}/toggle=
- [ ] *T050* [US1] [TDD] Write contract tests for =POST /api/relays/{id}/toggle=
- Test: Returns 200 with updated =RelayDto=
- Test: Returns 404 for id < 1 or id > 8
- Test: State actually changes in controller
- *File*: =tests/contract/test_relay_api.rs=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T051* [US1] [TDD] Implement =POST /api/relays/{id}/toggle= endpoint
- [ ] *T051* [US1] [TDD] Implement =POST /api/relays/{id}/toggle= endpoint
- ~#[oai(path = "/relays/:id/toggle", method = "post")]~
- Parse id, call =ToggleRelayUseCase=, return updated state
- *File*: =src/presentation/api/relay_api.rs=
- *Complexity*: Low | *Uncertainty*: Low
*** DONE Frontend Implementation [2/2]
CLOSED: [2026-05-15 ven. 03:57]
- State "DONE" from "TODO" [2026-05-15 ven. 03:57]
- [X] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface
*** TODO Frontend Implementation [0/2]
- [ ] *T052* [P] [US1] [TDD] Create =RelayDto= TypeScript interface
- Generate from OpenAPI spec or manually define
- *File*: =frontend/src/types/relay.ts=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T053* [P] [US1] [TDD] Create API client service
- [ ] *T053* [P] [US1] [TDD] Create API client service
- getAllRelays(): =Promise<RelayDto[]>=
- =toggleRelay(id: number): Promise=
- *File*: =frontend/src/api/relayApi.ts=
@@ -908,14 +823,12 @@ CLOSED: [2026-05-15 ven. 03:57]
--------------
*** DONE T046: HTTP Polling Composable (DECOMPOSED) [7/7]
CLOSED: [2026-05-15 ven. 03:59]
- State "DONE" from "TODO" [2026-05-15 ven. 03:59]
*** TODO T046: HTTP Polling Composable (DECOMPOSED) [0/7]
*Complexity*: High → Broken into 4 sub-tasks
*Uncertainty*: Medium
*Rationale*: Vue 3 lifecycle hooks, polling management, memory leak prevention
- [X] *T046a* [US1] [TDD] Create =useRelayPolling= composable structure
- [ ] *T046a* [US1] [TDD] Create =useRelayPolling= composable structure
- Setup reactive refs: =relays=, =isLoading=, =error=, =lastFetchTime=
- Define interval variable and fetch function signature
@@ -954,10 +867,10 @@ CLOSED: [2026-05-15 ven. 03:59]
*TDD Checklist*:
- [X] Test: Composable returns correct reactive refs
- [X] Test: Initial state is ~loading=true~, ~relays=[]~, ~error=null~
- [ ] Test: Composable returns correct reactive refs
- [ ] Test: Initial state is ~loading=true~, ~relays=[]~, ~error=null~
- [X] *T046b* [US1] [TDD] Implement =fetchData= with parallel requests
- [ ] *T046b* [US1] [TDD] Implement =fetchData= with parallel requests
- Fetch relays and health status in parallel using =Promise.all=
- Update reactive state on success
@@ -991,12 +904,12 @@ CLOSED: [2026-05-15 ven. 03:59]
*TDD Checklist*:
- [X] Test: =fetchData()= updates relays on success
- [X] Test: =fetchData()= sets error on API failure
- [X] Test: =fetchData()= sets ~isLoading=false~ after completion
- [X] Test: =fetchData()= updates =lastFetchTime=
- [ ] Test: =fetchData()= updates relays on success
- [ ] Test: =fetchData()= sets error on API failure
- [ ] Test: =fetchData()= sets ~isLoading=false~ after completion
- [ ] Test: =fetchData()= updates =lastFetchTime=
- [X] *T046c* [US1] [TDD] Implement polling lifecycle with cleanup
- [ ] *T046c* [US1] [TDD] Implement polling lifecycle with cleanup
- =startPolling()=: Fetch immediately, then =setInterval=
- =stopPolling()=: =clearInterval= and =cleanup=
@@ -1035,12 +948,12 @@ CLOSED: [2026-05-15 ven. 03:59]
*TDD Checklist*:
- [X] Test: =startPolling()= triggers immediate fetch
- [X] Test: =startPolling()= sets interval for subsequent fetches
- [X] Test: =stopPolling()= clears interval
- [X] Test: =onUnmounted= hook calls =stopPolling()=
- [ ] Test: =startPolling()= triggers immediate fetch
- [ ] Test: =startPolling()= sets interval for subsequent fetches
- [ ] Test: =stopPolling()= clears interval
- [ ] Test: =onUnmounted= hook calls =stopPolling()=
- [X] *T046d* [US1] [TDD] Add connection status tracking
- [ ] *T046d* [US1] [TDD] Add connection status tracking
- Track =isConnected= based on fetch success/failure
- Display connection status in UI
@@ -1065,25 +978,25 @@ CLOSED: [2026-05-15 ven. 03:59]
*TDD Checklist*:
- [X] Test: =isConnected= is true after successful fetch
- [X] Test: =isConnected= is false after failed fetch
- [ ] Test: =isConnected= is true after successful fetch
- [ ] Test: =isConnected= is false after failed fetch
--------------
- [X] *T055* [US1] [TDD] Create =RelayCard= component
- [ ] *T055* [US1] [TDD] Create =RelayCard= component
- Props: relay (=RelayDto=)
- Display relay ID, state, label
- Emit toggle event on button click
- *File*: =frontend/src/components/RelayCard.vue=
- *Complexity*: Low | *Uncertainty*: Low
- [X] *T056* [US1] [TDD] Create =RelayGrid= component
- [ ] *T056* [US1] [TDD] Create =RelayGrid= component
- Use =useRelayPolling= composable
- Render 8 RelayCard components
- Handle toggle events by calling API
- Display loading/error states
- *File*: =frontend/src/components/RelayGrid.vue=
- *Complexity*: Medium | *Uncertainty*: Low
- [X] *T057* [US1] [TDD] Integration test for US1
- [ ] *T057* [US1] [TDD] Integration test for US1
- End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change
- Use Playwright or Cypress
- *File*: =frontend/tests/e2e/relay-control.spec.ts=
+26 -11
View File
@@ -1,15 +1,30 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div class="min-h-screen flex flex-col">
<StaHeader />
<main class="grow px-6 py-10 max-w-4xl mx-auto w-full">
<RelaysView />
</main>
<StaFooter />
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<script setup lang="ts">
import StaHeader from './components/StaHeader.vue';
import StaFooter from './components/StaFooter.vue';
import RelaysView from './pages/RelaysView.vue';
</script>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
+8 -3
View File
@@ -13,12 +13,17 @@ To regenerate the TypeScript client after backend API changes:
1. Start the backend server:
```bash
just backend run
cargo run
```
2. Execute the update script:
2. Download the OpenAPI spec:
```bash
just frontend sync
curl http://localhost:3100/specs > openapi.yaml
```
3. Generate TypeScript types:
```bash
pnpm exec openapi-typescript openapi.yaml -o src/api/schema.ts
```
## Usage Example
+4 -4
View File
@@ -12,11 +12,11 @@
* ```
*/
import createClient from "openapi-fetch";
import type { paths } from "./schema";
import createClient from 'openapi-fetch';
import type { paths } from './schema';
// Get the API base URL from environment variables or default to localhost
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3100";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3100';
/**
* Typed API client instance.
@@ -28,4 +28,4 @@ export const apiClient = createClient<paths>({ baseUrl: API_BASE_URL });
/**
* Re-export the types for convenience
*/
export type { paths, components } from "./schema";
export type { paths, components } from './schema';
+11 -97
View File
@@ -1,7 +1,10 @@
/** This file was auto-generated by openapi-typescript. Do not make direct changes to the file. */
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/api/health': {
"/api/health": {
parameters: {
query?: never;
header?: never;
@@ -17,14 +20,14 @@ export interface paths {
};
requestBody?: never;
responses: {
/** Success */
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** Too Many Requests - rate limit exceeded */
/** @description Too Many Requests - rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
@@ -41,7 +44,7 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/meta': {
"/api/meta": {
parameters: {
query?: never;
header?: never;
@@ -57,16 +60,16 @@ export interface paths {
};
requestBody?: never;
responses: {
/** Success */
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json; charset=utf-8': components['schemas']['Meta'];
"application/json; charset=utf-8": components["schemas"]["Meta"];
};
};
/** Too Many Requests - rate limit exceeded */
/** @description Too Many Requests - rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
@@ -83,76 +86,6 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/relays': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json; charset=utf-8': components['schemas']['RelayDto'][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/relays/{id}/toggle': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json; charset=utf-8': components['schemas']['RelayDto'];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -162,25 +95,6 @@ export interface components {
version: string;
name: string;
};
/**
* RelayDto
*
* Data Transfer Object for relay information. This struct represents a relay in a serialized format suitable for
* API responses. It contains the relay's ID, current state, and label in a format that can be easily serialized to
* JSON.
*/
RelayDto: {
/**
* Format: uint8
*
* The relay's unique identifier (1-8).
*/
id: number;
/** The relay's current state as a string ("on" or "off"). */
state: string;
/** The relay's user-friendly label. */
label: string;
};
};
responses: never;
parameters: never;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
-69
View File
@@ -1,69 +0,0 @@
<template>
<div
:class="
'relay flex flex-col gap-10 bg-background-100 rounded-lg border-2 p-6 transition-all duration-300 ' +
relayClass
"
>
<div class="flex flex-row justify-between items-center">
<div class="flex flex-row gap-3 items-center">
<i class="pi pi-circle-fill"></i> <i class="pi pi-power-off"></i>
</div>
<div>
<Badge
:value="'Relay ' + props.relay.id"
:severity="isRelayOn ? 'primary' : 'secondary'"
/>
</div>
</div>
<div class="flex flex-row justify-between items-center">
<div>{{ props.relay.label }}</div>
<ToggleSwitch v-model="isRelayOn" v-on:click="toggleRelay(relay.id)" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRelay } from '../composables/useRelay';
import { RelayState, type Relay } from '../types/relay';
import { Badge, ToggleSwitch } from 'primevue';
const props = defineProps<{
relay: Relay;
}>();
const isRelayOn = computed(() => props.relay.state === RelayState.On);
const relayClass = computed(() => {
if (props.relay.state === RelayState.Off) {
return 'border-secondary shadow-md relay-off';
}
return 'border-primary shadow-lg shadow-primary-200 relay-on';
});
const { toggleRelay } = useRelay();
</script>
<style lang="less" scoped>
.relay {
width: 15rem;
&:hover {
scale: 1.02;
}
}
i {
font-weight: 700;
font-size: 1.5rem;
&.pi-circle-fill {
font-size: 1.15rem;
}
.relay-on & {
color: var(--color-primary);
}
.relay-off & {
color: var(--color-secondary-400);
}
}
</style>
-43
View File
@@ -1,43 +0,0 @@
<template>
<footer
class="bg-background-200 border-t border-background-200 px-6 py-4 text-sm text-text"
>
<div class="max-w-4xl mx-auto text-center">
&copy; {{ currentYear }} {{ appName }} &dash; Lucien Cartier-Tilet.
<a href="https://labs.phundrak.com/phundrak/sta"> Source code </a>
under the
<a
href="https://labs.phundrak.com/phundrak/sta/src/branch/develop/LICENSE.md"
>
AGPL 3.0 license </a
>.
</div>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useMeta } from '../composables/useMeta';
import { isNil } from '../utils/isNil';
const currentYear = new Date().getFullYear();
const { metadata } = useMeta();
const appName = computed(() =>
isNil(metadata.value)
? 'STA'
: `${metadata.value.name} v${metadata.value.version}`,
);
</script>
<style scoped="scoped">
a {
color: var(--color-secondary-500);
}
@layer base {
a {
@apply underline decoration-wavy underline-offset-2;
}
}
</style>
-11
View File
@@ -1,11 +0,0 @@
<template>
<header
class="sticky top-0 z-10 bg-background-200 border-b border-background-200 shadow-sm px-6 py-4"
>
<nav class="flex items-center justify-between max-w-4xl mx-auto">
<span class="text-lg font-heading">
STA &dash; Smart Temperature & Appliance Control
</span>
</nav>
</header>
</template>
-30
View File
@@ -1,30 +0,0 @@
import { onMounted, ref } from 'vue';
import { apiClient } from '../api/client';
import type { components } from '../api/schema';
type Meta = components['schemas']['Meta'];
export function useMeta() {
const isLoading = ref(false);
const metadata = ref<Meta | null>(null);
const error = ref<string | null>(null);
const getMetadata = async () => {
isLoading.value = true;
try {
const { data } = await apiClient.GET('/api/meta');
error.value = null;
metadata.value = data as Meta;
} catch (err: any) {
console.error('Failed to fetch metadata:', err);
error.value = err.message || 'Failed to fetch metadata';
} finally {
isLoading.value = false;
}
};
onMounted(getMetadata);
return { isLoading, metadata, error };
}
-38
View File
@@ -1,38 +0,0 @@
import { ref } from 'vue';
import { apiClient } from '../api/client';
import { relayDtoToDomain } from '../types/mappers/relayDtoMapper';
import type { Relay, RelayDto } from '../types/relay';
export function useRelay() {
const isLoading = ref(false);
const error = ref<string | null>(null);
const response = ref<Relay | null>(null);
const toggleRelay = async (id: number) => {
isLoading.value = true;
try {
const { data } = await apiClient.POST('/api/relays/{id}/toggle', {
params: {
path: {
id,
},
},
});
error.value = null;
response.value = relayDtoToDomain(data as RelayDto);
} catch (err: any) {
console.error(`Failed to toggle relay ${id}:`, err);
error.value = err.message || `Failed to toggle relay ${id}`;
} finally {
isLoading.value = false;
}
};
return {
toggleRelay,
isLoading,
error,
response,
};
}
-51
View File
@@ -1,51 +0,0 @@
import { onMounted, onUnmounted, ref } from 'vue';
import { apiClient } from '../api/client';
import { relayDtoToDomain } from '../types/mappers/relayDtoMapper';
import type { Relay } from '../types/relay';
import { isNil } from '../utils/isNil';
export function useRelayPolling(intervalMs: number = 2000) {
const relays = ref<Relay[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
let pollingInterval: number | null = null;
const fetchData = async () => {
isLoading.value = true;
try {
const { data } = await apiClient.GET('/api/relays');
relays.value = data?.map(relayDtoToDomain) ?? [];
error.value = null;
} catch (err: any) {
console.error('Polling error:', err);
error.value = err.message || 'Failed to fetch data';
} finally {
isLoading.value = false;
}
};
const startPolling = () => {
fetchData();
pollingInterval = window.setInterval(fetchData, intervalMs);
};
const stopPolling = () => {
if (isNil(pollingInterval)) {
return;
}
clearInterval(pollingInterval);
pollingInterval = null;
};
onMounted(startPolling);
onUnmounted(stopPolling);
return {
relays,
isLoading,
error,
refresh: fetchData,
};
}
+4 -15
View File
@@ -1,16 +1,5 @@
import Lara from '@primeuix/themes/lara';
import PrimeVue from 'primevue/config';
import { createApp } from 'vue';
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import 'primeicons/primeicons.css';
import './style.css';
import App from './App.vue';
const app = createApp(App);
app.use(PrimeVue, {
theme: {
preset: Lara,
},
ripple: true,
});
app.mount('#app');
createApp(App).mount('#app')
-30
View File
@@ -1,30 +0,0 @@
<template>
<div class="flex flex-col gap-8">
<div v-if="isLoading && !relays">
<ProgressSpinner class="--p-progressspinner-color-primary" />
</div>
<div
v-else-if="error"
class="bg-accent text-background py-4 px-3 rounded-md"
>
{{ error }}
</div>
<div v-else class="flex flex-row flex-wrap gap-4">
<RelayCard v-for="relay in relays" :relay="relay" />
</div>
<div class="flex flex-row flex-wrap justify-evenly" style="display: none">
<Button severity="primary" class="min-w-2xs">Tout activer</Button>
<Button severity="secondary" class="min-w-2xs">Tout désactiver</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRelayPolling } from '../composables/useRelayPolling';
import { ProgressSpinner } from 'primevue';
import RelayCard from '../components/RelayCard.vue';
import { Button } from 'primevue';
const { relays, isLoading, error, refresh } = useRelayPolling();
refresh();
</script>
+75 -122
View File
@@ -1,126 +1,79 @@
@import url('https://fonts.googleapis.com/css?family=Plus%20Jakarta%20Sans:700|Noto%20Sans:400');
@import "tailwindcss";
@theme {
--font-jakarta: Plus Jakarta Sans, sans-serif;
--font-heading: Plus Jakarta Sans, sans-serif;
--font-noto: Noto Sans, sans-serif;
--font-body: Noto Sans, sans-serif;
--font-normal: 400;
--font-bold: 700;
--text-sm: 0.750rem;
--text-base: 1rem;
--text-xl: 1.333rem;
--text-2xl: 1.777rem;
--text-3xl: 2.369rem;
--text-4xl: 3.158rem;
--text-5xl: 4.210rem;
--color-text: oklch(20.55% 0.026 159.60);
--color-text-50: oklch(96.73% 0.012 164.80);
--color-text-100: oklch(93.53% 0.024 163.13);
--color-text-200: oklch(87.08% 0.048 162.29);
--color-text-300: oklch(80.85% 0.075 161.20);
--color-text-400: oklch(74.56% 0.099 159.20);
--color-text-500: oklch(68.48% 0.121 157.47);
--color-text-600: oklch(58.25% 0.101 157.47);
--color-text-700: oklch(47.56% 0.080 158.24);
--color-text-800: oklch(35.96% 0.056 158.77);
--color-text-900: oklch(23.61% 0.032 159.65);
--color-text-950: oklch(16.99% 0.020 157.52);
--color-background: oklch(98.85% 0.003 174.49);
--color-background-50: oklch(96.66% 0.009 179.60);
--color-background-100: oklch(93.48% 0.020 172.77);
--color-background-200: oklch(86.98% 0.039 173.82);
--color-background-300: oklch(80.46% 0.058 172.26);
--color-background-400: oklch(74.00% 0.077 170.71);
--color-background-500: oklch(67.67% 0.094 169.62);
--color-background-600: oklch(57.52% 0.079 169.17);
--color-background-700: oklch(46.93% 0.062 169.68);
--color-background-800: oklch(35.70% 0.045 170.66);
--color-background-900: oklch(23.47% 0.026 169.60);
--color-background-950: oklch(16.82% 0.014 169.51);
--color-primary: oklch(70.75% 0.113 157.63);
--color-primary-50: oklch(96.73% 0.012 164.80);
--color-primary-100: oklch(93.53% 0.024 163.13);
--color-primary-200: oklch(87.05% 0.049 161.02);
--color-primary-300: oklch(80.82% 0.076 160.38);
--color-primary-400: oklch(74.54% 0.100 158.60);
--color-primary-500: oklch(68.46% 0.122 157.00);
--color-primary-600: oklch(58.22% 0.102 156.89);
--color-primary-700: oklch(47.54% 0.081 157.46);
--color-primary-800: oklch(35.94% 0.057 157.56);
--color-primary-900: oklch(23.61% 0.032 159.65);
--color-primary-950: oklch(16.99% 0.020 157.52);
--color-secondary: oklch(77.49% 0.049 254.33);
--color-secondary-50: oklch(95.88% 0.009 247.92);
--color-secondary-100: oklch(91.80% 0.017 250.85);
--color-secondary-200: oklch(83.27% 0.035 253.73);
--color-secondary-300: oklch(74.79% 0.055 252.87);
--color-secondary-400: oklch(66.02% 0.075 253.94);
--color-secondary-500: oklch(57.42% 0.096 253.86);
--color-secondary-600: oklch(48.91% 0.081 254.25);
--color-secondary-700: oklch(40.26% 0.064 253.43);
--color-secondary-800: oklch(30.86% 0.044 254.23);
--color-secondary-900: oklch(20.97% 0.024 251.59);
--color-secondary-950: oklch(15.30% 0.015 257.65);
--color-accent: oklch(62.74% 0.101 280.46);
--color-accent-50: oklch(95.09% 0.012 281.08);
--color-accent-100: oklch(90.22% 0.024 283.36);
--color-accent-200: oklch(80.23% 0.051 282.68);
--color-accent-300: oklch(69.81% 0.082 281.67);
--color-accent-400: oklch(59.46% 0.112 280.05);
--color-accent-500: oklch(49.09% 0.144 277.36);
--color-accent-600: oklch(42.01% 0.120 277.54);
--color-accent-700: oklch(34.62% 0.096 277.83);
--color-accent-800: oklch(27.07% 0.066 278.62);
--color-accent-900: oklch(18.71% 0.036 279.84);
--color-accent-950: oklch(14.04% 0.022 283.20);
}
:root {
--p-button-primary-background: var(--color-primary) !important;
--p-button-primary-border-color: var(--color-primary) !important;
--p-button-primary-hover-background: var(--color-primary-400) !important;
--p-button-primary-hover-border-color: var(--color-primary-400) !important;
--p-button-primary-active-background: var(--color-primary-300) !important;
--p-button-primary-active-border-color: var(--color-primary-300) !important;
--p-button-primary-color: var(--color-text) !important;
--p-button-primary-hover-color: var(--color-text) !important;
--p-button-primary-active-color: var(--color-text) !important;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
--p-button-secondary-background: var(--color-secondary) !important;
--p-button-secondary-border-color: var(--color-secondary) !important;
--p-button-secondary-hover-background: var(--color-secondary-400) !important;
--p-button-secondary-hover-border-color: var(--color-secondary-400) !important;
--p-button-secondary-active-background: var(--color-secondary-300) !important;
--p-button-secondary-active-border-color: var(--color-secondary-300) !important;
--p-button-secondary-color: var(--color-text) !important;
--p-button-secondary-hover-color: var(--color-text) !important;
--p-button-secondary-active-color: var(--color-text) !important;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--p-toggleswitch-border-color: var(--color-secondary-700) !important;
--p-toggleswitch-background: var(--color-secondary-50) !important;
--p-toggleswitch-handle-background: var(--color-secondary-700) !important;
--p-toggleswitch-hover-border-color: var(--color-secondary-500) !important;
--p-toggleswitch-hover-background: var(--color-secondary-50) !important;
--p-toggleswitch-handle-hover-background: var(--color-secondary-500) !important;
--p-toggleswitch-checked-background: var(--color-primary-400) !important;
--p-toggleswitch-handle-checked-background: var(--color-primary-800) !important;
--p-toggleswitch-checked-hover-background: var(--color-primary-300) !important;
--p-toggleswitch-handle-checked-hover-background: var(--color-primary-700) !important;
--p-badge-primary-background: var(--color-primary) !important;
--p-badge-primary-color: var(--color-text) !important;
--p-badge-secondary-background: var(--color-secondary-400) !important;
--p-badge-secondary-color: var(--color-text) !important;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
-13
View File
@@ -1,13 +0,0 @@
import { isNil } from '../../utils/isNil';
import { RelayState, Relay, type RelayDto } from '../relay';
const relayStateToDomain = (dto: string | null): RelayState => {
if (isNil(dto) || dto.trim() === '') {
return RelayState.Off;
}
return dto.trim().toLowerCase() === 'on' ? RelayState.On : RelayState.Off;
};
export const relayDtoToDomain = (dto: RelayDto): Relay => {
return new Relay(dto.id, relayStateToDomain(dto.state), dto.label);
};
-20
View File
@@ -1,20 +0,0 @@
import type { components } from '../api/schema';
export type RelayDto = components['schemas']['RelayDto'];
export enum RelayState {
On = 'on',
Off = 'off',
}
export class Relay {
id: number;
state: RelayState;
label: string;
constructor(id: number, state: RelayState, label: string) {
this.id = id;
this.state = state;
this.label = label;
}
}
-2
View File
@@ -1,2 +0,0 @@
export const isNil = (value: unknown | null | undefined): value is null | undefined =>
value === null || value === undefined;
+2 -3
View File
@@ -8,10 +8,9 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+3 -8
View File
@@ -1,12 +1,7 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"exclude": [".direnv/**/*"]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+2 -3
View File
@@ -18,10 +18,9 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+4 -13
View File
@@ -1,16 +1,7 @@
import * as path from 'path';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
plugins: [vue()],
})