diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2269e22 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,186 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is **Decatur Makers Machine Access Control (dm-mac)**: a software and hardware project for using RFID cards/fobs to control access to power tools and equipment in the Decatur Makers makerspace. The system consists of: + +1. **Central Control Server**: Python/Quart (async Flask) application that handles authentication/authorization, machine control, and logging +2. **Machine Control Units (MCUs)**: ESP32-based hardware running ESPHome that communicate with the server + +The system integrates with NeonOne CRM for user data (optional and pluggable). + +## Development Commands + +### Environment Setup +```bash +# Install dependencies +poetry install + +# Activate virtualenv (if needed) +poetry shell + +# Install pre-commit hooks +nox -s pre-commit -- install +``` + +### Testing +```bash +# Run all tests with coverage +nox -s tests + +# Run a single test file +nox -s tests -- tests/test_utils.py + +# Run a specific test +nox -s tests -- tests/test_utils.py::test_specific_function + +# Run tests with typeguard runtime type checking +nox -s typeguard +``` + +### Code Quality +```bash +# Run all linting/formatting checks +nox -s pre-commit + +# Run type checking +nox -s mypy + +# Run security checks +nox -s safety + +# Check coverage report +nox -s coverage -- report +nox -s coverage -- html # generates htmlcov/index.html +``` + +### Documentation +```bash +# Build docs +nox -s docs + +# Build docs with auto-rebuild and browser +DOCS_REBUILD=true nox -s docs +``` + +### Running the Server +```bash +# Run the MAC server (default port 5000) +poetry run mac-server + +# Run with debug mode +poetry run mac-server --debug + +# Run with verbose logging +poetry run mac-server --verbose + +# Run on custom port +poetry run mac-server --port 8080 +``` + +### NeonGetter Tool +```bash +# Update users.json from NeonOne CRM +poetry run neongetter +``` + +## Architecture + +### Core Components + +**Application Factory Pattern**: The Quart app is created via `create_app()` in `src/dm_mac/__init__.py`. The app configuration includes: +- `MACHINES`: MachinesConfig instance managing all machine configurations +- `USERS`: UsersConfig instance managing all user data +- `SLACK_HANDLER`: Optional SlackHandler for Slack integration +- `START_TIME`: Server start timestamp for uptime tracking + +**Configuration System**: +- Machines: `machines.json` (schema in `models/machine.py::CONFIG_SCHEMA`) + - `authorizations_or`: List of authorizations, any one sufficient to operate + - `unauthorized_warn_only`: (optional) Allow operation but log warning for unauthorized users + - `always_enabled`: (optional) Machine always enabled without RFID authentication, displays "Always On" +- Users: `users.json` (schema in `models/users.py::CONFIG_SCHEMA`) +- Machine names must match ESPHome configs and can only contain `[a-z0-9_-]` + +**State Persistence**: +- Machine state is persisted to disk on every update using pickle +- Default location: `./machine_state/` (configurable via `MACHINE_STATE_DIR` env var) +- File locking via `filelock` ensures thread-safe state updates +- Enables server restarts without affecting running machines + +### Request Flow + +1. **MCU Update Request**: ESP32 POSTs to `/machine/update` with current state (RFID value, oops button, uptime, WiFi signal, temperature, optional amperage) +2. **Authentication**: Server looks up user by RFID fob code (zero-padded to 10 chars) +3. **Authorization**: Checks if user has any of the required authorizations from `machines.json::authorizations_or` list +4. **State Update**: Updates machine state, persists to disk, optionally logs to Slack +5. **Response**: Returns JSON with desired MCU outputs (relay state, LCD text, LED colors) + +### Key Models + +**Machine** (`models/machine.py`): +- `name`: Unique machine identifier +- `authorizations_or`: List of authorizations, any one sufficient to operate +- `unauthorized_warn_only`: If true, log warning but allow operation for unauthorized users +- `state`: MachineState instance tracking current operator, session timing, lockout status + +**User** (`models/users.py`): +- `fob_codes`: List of RFID fob codes (10-digit strings) +- `account_id`: Unique account identifier +- `authorizations`: List of training/authorization field names +- `expiration_ymd`: Membership expiration in YYYY-MM-DD format + +### API Endpoints + +**Machine APIs** (`/machine/*`): +- `POST /machine/update`: Main endpoint for MCU state updates +- `POST /machine/lock/`: Lock out a machine +- `POST /machine/unlock/`: Unlock a machine + +**Admin APIs** (`/api/*`): +- `POST /api/reload-users`: Hot-reload users.json without restart +- `GET /metrics`: Prometheus metrics endpoint + +### Logging + +Custom `RequestFormatter` adds request context (`remote_addr`, `url`) to all logs when available. The `AUTH` logger is used specifically for authentication/authorization decisions. + +## Environment Variables + +Required for NeonGetter: +- `NEON_ORG`: NeonOne organization name +- `NEON_KEY`: NeonOne API key +- `NEONGETTER_CONFIG`: Path to neon config JSON + +Optional for MAC server: +- `USERS_CONFIG`: Path to users.json (default: `./users.json`) +- `MACHINES_CONFIG`: Path to machines.json (default: `./machines.json`) +- `MACHINE_STATE_DIR`: State persistence directory (default: `./machine_state`) +- `SLACK_BOT_TOKEN`: Slack Bot User OAuth Token +- `SLACK_APP_TOKEN`: Slack Socket OAuth Token +- `SLACK_SIGNING_SECRET`: Slack Signing Secret +- `SLACK_CONTROL_CHANNEL_ID`: Private admin channel ID +- `SLACK_OOPS_CHANNEL_ID`: Public channel for oops/maintenance notices + +## Testing Notes + +- Tests use fixtures in `tests/fixtures/` for config files +- Test environment variables are set in `noxfile.py::TEST_ENV` +- Async tests use `pytest-asyncio` with `--asyncio-mode=auto` +- Network blocking enforced via `pytest-blockage` to prevent accidental external calls +- Coverage threshold: 5% (intentionally low for early-stage project) + +## Important Implementation Details + +- RFID values from ESPHome have leading zeroes stripped; the server left-pads to 10 characters +- Machine state updates use both in-memory caching and disk persistence +- All machine state operations are protected by file locks to prevent race conditions +- The server uses asyncio event loop with custom exception handler +- Slack integration uses Socket Mode (bidirectional WebSocket) +- Machines can be configured with `unauthorized_warn_only: true` for training/soft-enforcement mode + +## Feature Development + +We have a special process for developing features. When asked to begin work on a feature, you MUST read and understand all of `docs/features/README.md` which outlines our feature development process. Once you have read and understood that document, ask the user which of the `docs/features/*.md` Features they want to begin work on; once one is chosen, begin work on the feature development process. diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 0000000..a743672 --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,18 @@ +# Features + +This directory contains markdown files describing features that we want to implement for this project. Each feature initially just includes a human-generated explanation; for each feature you (Claude Code, the AI coding assistant) will update that document to include an implementation plan to resolve the feature and then await human approval before proceeding. You are encouraged to solicit human input/feedback during the planning phase for anything you have questions about or do not feel is clear. Once planning is complete, if you get confused or are unable to accomplish a feature without significant issues, please ask for human feedback. You MUST plan one feature at a time, in order, and then implement that feature. As earlier features may inform or change the implementation of later ones, we will work one feature at a time from planning through implementation, completion, and human validation, before moving on to the next. + +The following guidelines MUST always be followed: + +* Features that are non-trivial in size (i.e. more than a few simple changes) should be broken down into Milestones and Tasks. Those will be given a prefix to be used in commit messages, formatted as `{Feature Name} - {Milestone number}.{Task number}`. Human approval must always be obtained to move from one Milestone to the next. +* At the end of every Milestone and Feature you must (in order): + 1. Update the feature markdown document to indicate what progress has been made on the relevant Milestone or Feature. + 2. Run all `nox` tests and ensure that ALL tests are passing. You MAY NOT consider a Milestone complete until ALL tests that were passing at the beginning of the Milestone are still passing, unless given explicit human approval to defer testing until later. + 3. Commit that along with all changes to git, using a commit message beginning with the Milestone/Task prefix and a one-sentence concise summary of the changes followed by a detailed explanation of the changes. +* Every feature must end with an "Acceptance Criteria" Milestone. This Milestone must include tasks to: + 1. Ensure that all appropriate documentation (`README.md`, `docs/source/`, and `CLAUDE.md`) is updated as needed for the work done as part of the feature. Documentation should be easily readable, concise, and a match to the style, tone, and verbosity of the existing documentation. + 2. All code changes have appropriate unit test (`nox -s tests`) coverage. + 3. ALL nox sessions must be passing successfully. + 4. As the last step of every feature, move the feature markdown file from `docs/features/` to `docs/features/completed/`. +* If you become confused or unclear on how to proceed, have to make a significant decision not explicitly included in the implementation plan, or find yourself making changes and then undoing them without a clear and certain path forward, you must stop and ask for human guidance. +* From time to time we may identify a new, more pressing issue while implementing a feature; we refer to these as "side quests". When beginning a side quest you must update this document to include detailed information on exactly where we're departing from our feature implementation, such that we could use this document to resume from where we left off in a new session, and then commit that. When the side quest is complete, we will resume our feature work. diff --git a/docs/features/completed/.gitkeep b/docs/features/completed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/features/completed/always-enabled-machine.md b/docs/features/completed/always-enabled-machine.md new file mode 100644 index 0000000..55e0025 --- /dev/null +++ b/docs/features/completed/always-enabled-machine.md @@ -0,0 +1,193 @@ +# Always-Enabled Machine + +## Feature Description + +Right now, Machines can be configured to accept a list of authorizations and optionally set to `unauthorized_warn_only` mode. I would like to now add another `always_enabled` boolean to the Machine configuration which, if True, causes the machine to ALWAYS be authorized/enabled unless it is Oopsed. When in this state, the display of the machine should read "Always On". Please be sure to update the Machine CONFIG_SCHEMA, the Machine model itself, the MachineState model, all other relevant code, and all relevant documentation. + +Please be sure to add unit tests for this new functionality for AT LEAST the following cases: + +1. A machine with `always_enabled` True always has `Always On` on its display and always has its relay output turned on, unless Oopsed. +2. A machine with `always_enabled` True exhibits the same Oops behavior as existing tests. +3. A machine with `always_enabled` True does not change state when an RFID card is inserted or removed. +4. A machine with `always_enabled` True becomes enabled immediately when it contacts the server, unless Oopsed. + +## Implementation Plan + +### Overview + +The implementation will add a new `always_enabled` boolean configuration option to machines. When enabled, the machine will: +- Always have its relay on (unless Oopsed or Locked) +- Display "Always On" text on the LCD (unless Oopsed or Locked) +- Ignore RFID card insertions/removals (no user authentication required) +- Be immediately enabled when it first contacts the server + +Key files to modify: +- `src/dm_mac/models/machine.py`: Machine model, CONFIG_SCHEMA, and MachineState logic +- `tests/models/test_machine.py`: Model tests +- `tests/views/test_machine.py`: Integration tests for `/machine/update` endpoint +- Documentation files as needed + +### Milestone 1: Configuration and Model Updates + +**Commit prefix:** `always-enabled - 1.1` through `always-enabled - 1.3` + +#### Task 1.1: Update CONFIG_SCHEMA +- Add `always_enabled` boolean property to CONFIG_SCHEMA in `src/dm_mac/models/machine.py` +- Set as optional field with clear description +- Ensure schema validation works correctly + +#### Task 1.2: Update Machine class +- Add `always_enabled: bool` attribute to Machine class `__init__` method +- Default value should be `False` for backward compatibility +- Update type hints appropriately + +#### Task 1.3: Update Machine.as_dict property +- Include `always_enabled` in the dictionary returned by `as_dict` property +- Add basic model test to verify `always_enabled` appears in `as_dict` output + +**Milestone completion criteria:** +- Machine model can be instantiated with `always_enabled=True` +- CONFIG_SCHEMA validates configurations with `always_enabled` field +- All existing tests still pass + +### Milestone 2: State Logic Updates + +**Commit prefix:** `always-enabled - 2.1` through `always-enabled - 2.3` + +#### Task 2.1: Add ALWAYS_ON_DISPLAY_TEXT constant +- Add constant `ALWAYS_ON_DISPLAY_TEXT = "Always On"` to MachineState class +- Position it near other display text constants (lines 184-188) + +#### Task 2.2: Update MachineState.update() for always-enabled logic +- Modify `MachineState.update()` method (currently lines 364-408) +- After handling Oops/Lockout states, check if `machine.always_enabled` is True +- If always-enabled and not Oopsed/Locked: + - Set `self.relay_desired_state = True` + - Set `self.display_text = self.ALWAYS_ON_DISPLAY_TEXT` + - Skip RFID processing (return early before RFID insert/remove handlers) +- Ensure Oops and Lockout states still override always-enabled behavior + +#### Task 2.3: Handle initial state for always-enabled machines +- Ensure that when an always-enabled machine first contacts the server (with no RFID), it: + - Gets `relay_desired_state = True` + - Gets `display_text = "Always On"` + - Has status LED set to green (0.0, 1.0, 0.0) +- This should happen in the "no RFID change" code path + +**Milestone completion criteria:** +- Always-enabled machines show "Always On" and relay=True when not Oopsed +- Always-enabled machines respect Oops and Lockout states +- RFID cards are ignored when machine is always-enabled +- All existing tests still pass + +### Milestone 3: Unit Tests + +**Commit prefix:** `always-enabled - 3.1` through `always-enabled - 3.4` + +Add comprehensive test coverage in `tests/views/test_machine.py`: + +#### Task 3.1: Test always-enabled basic behavior +- Create test class `TestAlwaysEnabledMachine` +- Test: `test_always_enabled_basic()` + - Machine with `always_enabled: true` in config + - POST to `/machine/update` with no RFID + - Assert response: `relay=True`, `display="Always On"`, green LED + - Verify state persisted to disk + +#### Task 3.2: Test always-enabled with Oops +- Test: `test_always_enabled_oopsed()` + - Machine with `always_enabled: true` + - POST with `oops=true` + - Assert response: `relay=False`, display=OOPS_DISPLAY_TEXT, red LED + - POST with `oops=false` after Oops cleared + - Assert returns to: `relay=True`, `display="Always On"`, green LED + +#### Task 3.3: Test always-enabled ignores RFID +- Test: `test_always_enabled_ignores_rfid_insert()` + - Machine with `always_enabled: true` + - POST with RFID value (authorized user) + - Assert response: `relay=True`, `display="Always On"` (NOT welcome message) +- Test: `test_always_enabled_ignores_rfid_remove()` + - Machine with `always_enabled: true`, RFID already present + - POST with empty RFID value + - Assert response: `relay=True`, `display="Always On"` (no change) + +#### Task 3.4: Test always-enabled immediate enable +- Test: `test_always_enabled_first_contact()` + - Fresh machine state (no previous contact) + - POST with no RFID, no Oops + - Assert response: `relay=True`, `display="Always On"`, green LED immediately + +**Milestone completion criteria:** +- All 5+ new tests pass +- Tests cover all 4 required cases from feature spec +- All existing tests still pass +- Coverage for always-enabled code paths + +### Milestone 4: Acceptance Criteria + +**Commit prefix:** `always-enabled - 4.1` through `always-enabled - 4.4` + +#### Task 4.1: Update documentation +- Update `CLAUDE.md`: Add `always_enabled` to configuration options description +- Update `README.md` (if configuration section exists): Document `always_enabled` option +- Update `docs/source/` Sphinx docs (if applicable): Add to machine configuration reference +- Ensure documentation style matches existing docs (concise, technical) + +#### Task 4.2: Verify unit test coverage +- Run `nox -s coverage -- report` to check coverage +- Ensure new code has appropriate test coverage (aim for >80% of new lines) +- Add any missing tests if gaps are identified + +#### Task 4.3: Verify all nox sessions pass +- Run `nox -s tests` - must be 100% passing +- Run `nox -s mypy` - must pass with no errors +- Run `nox -s pre-commit` - must pass all checks +- Run `nox -s safety` - must pass +- Fix any issues that arise + +#### Task 4.4: Move feature to completed +- Move `docs/features/always-enabled-machine.md` to `docs/features/completed/always-enabled-machine.md` +- Commit with message: "always-enabled - 4.4: feature complete" + +**Milestone completion criteria:** +- All documentation updated +- All nox sessions passing +- Feature file moved to completed/ +- Feature fully implemented and validated + +## Implementation Status + +**Status:** ✅ COMPLETE + +**All Milestones Completed:** + +- **Milestone 1: Configuration and Model Updates** ✅ + - Added `always_enabled` boolean to CONFIG_SCHEMA + - Added `always_enabled` attribute to Machine class + - Updated `as_dict` property to include `always_enabled` + +- **Milestone 2: State Logic Updates** ✅ + - Added `ALWAYS_ON_DISPLAY_TEXT` constant to MachineState + - Updated `MachineState.update()` to handle always-enabled machines + - Always-enabled machines skip RFID processing and show "Always On" + - Updated `unoops()` to restore always-on state for always-enabled machines + +- **Milestone 3: Unit Tests** ✅ + - Created 5 comprehensive tests for always-enabled functionality + - Added always-on-machine to test fixtures + - Fixed all test expectations for machine count changes (5 -> 6) + - Updated Slack handler tests for new machine + - Updated Prometheus test fixtures for all metric types + +- **Milestone 4: Acceptance Criteria** ✅ + - Documentation updated (CLAUDE.md) + - 100% test coverage verified + - All nox sessions passing (tests, mypy, pre-commit, safety) + - Feature file moved to completed/ + +**Final Status:** +- All 146 tests passing +- 100% code coverage +- All nox quality checks passing +- Feature fully implemented and validated diff --git a/docs/source/hardware.rst b/docs/source/hardware.rst index 565a1e5..c61fe5c 100644 --- a/docs/source/hardware.rst +++ b/docs/source/hardware.rst @@ -130,6 +130,9 @@ This is intended to work with `esphome-configs/2024.6.4/no-current-input.yaml "] license = "MIT" diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index e17ff6a..dfa3430 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -53,6 +53,13 @@ "but log and display a warning if the " "operator is not authorized.", }, + "always_enabled": { + "type": "boolean", + "description": "If set, machine is always enabled and " + "does not require RFID authentication. " + "Displays 'Always On' and relay is always " + "on unless Oopsed or Locked.", + }, }, "additionalProperties": False, "description": "Unique machine name, alphanumeric _ and - only.", @@ -69,6 +76,7 @@ def __init__( name: str, authorizations_or: List[str], unauthorized_warn_only: bool = False, + always_enabled: bool = False, ): """Initialize a new MachineState instance.""" #: The name of the machine @@ -78,6 +86,8 @@ def __init__( #: Whether to allow anyone to operate machine regardless of #: authorization, just logging/displaying a warning if unauthorized self.unauthorized_warn_only: bool = unauthorized_warn_only + #: Whether machine is always enabled without RFID authentication + self.always_enabled: bool = always_enabled #: state of the machine self.state: "MachineState" = MachineState(self) @@ -142,6 +152,7 @@ def as_dict(self) -> Dict[str, Any]: "name": self.name, "authorizations_or": self.authorizations_or, "unauthorized_warn_only": self.unauthorized_warn_only, + "always_enabled": self.always_enabled, } @@ -187,6 +198,8 @@ class MachineState: LOCKOUT_DISPLAY_TEXT: str = "Down for\nmaintenance" + ALWAYS_ON_DISPLAY_TEXT: str = "Always On" + STATUS_LED_BRIGHTNESS: float = 0.5 def __init__(self, machine: Machine, load_state: bool = True): @@ -290,18 +303,25 @@ def _load_from_cache(self) -> None: async def _handle_reboot(self) -> None: """Handle when the ESP32 (MCU) has rebooted since last checkin. - This logs out the current user if logged in and turns off the relay if - turned on. + This logs out the current user if logged in and resets the machine state. + For always-enabled machines, restores the always-on state. """ logging.getLogger("AUTH").warning( "Machine %s rebooted; resetting relay and RFID state", self.machine.name ) # locking handled in update() - self.relay_desired_state = False self.current_user = None - self.display_text = self.DEFAULT_DISPLAY_TEXT - self.status_led_rgb = (0.0, 0.0, 0.0) - self.status_led_brightness = 0.0 + # Restore always-enabled state if applicable + if self.machine.always_enabled: + self.relay_desired_state = True + self.display_text = self.ALWAYS_ON_DISPLAY_TEXT + self.status_led_rgb = (0.0, 1.0, 0.0) + self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + else: + self.relay_desired_state = False + self.display_text = self.DEFAULT_DISPLAY_TEXT + self.status_led_rgb = (0.0, 0.0, 0.0) + self.status_led_brightness = 0.0 # log to Slack, if enabled slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER") if not slack: @@ -329,11 +349,18 @@ def unlock(self) -> None: ) with self._lock: self.is_locked_out = False - self.relay_desired_state = False self.current_user = None - self.display_text = self.DEFAULT_DISPLAY_TEXT - self.status_led_rgb = (0.0, 0.0, 0.0) - self.status_led_brightness = 0.0 + # Restore always-enabled state if applicable + if self.machine.always_enabled: + self.relay_desired_state = True + self.display_text = self.ALWAYS_ON_DISPLAY_TEXT + self.status_led_rgb = (0.0, 1.0, 0.0) + self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + else: + self.relay_desired_state = False + self.display_text = self.DEFAULT_DISPLAY_TEXT + self.status_led_rgb = (0.0, 0.0, 0.0) + self.status_led_brightness = 0.0 def oops(self, do_locking: bool = True) -> None: """Oops the machine.""" @@ -355,11 +382,18 @@ def unoops(self, do_locking: bool = True) -> None: locker = self._lock if do_locking else nullcontext() with locker: self.is_oopsed = False - self.relay_desired_state = False self.current_user = None - self.display_text = self.DEFAULT_DISPLAY_TEXT - self.status_led_rgb = (0.0, 0.0, 0.0) - self.status_led_brightness = 0 + # Restore always-enabled state if applicable + if self.machine.always_enabled: + self.relay_desired_state = True + self.display_text = self.ALWAYS_ON_DISPLAY_TEXT + self.status_led_rgb = (0.0, 1.0, 0.0) + self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + else: + self.relay_desired_state = False + self.display_text = self.DEFAULT_DISPLAY_TEXT + self.status_led_rgb = (0.0, 0.0, 0.0) + self.status_led_brightness = 0 async def update( self, @@ -398,7 +432,21 @@ async def update( if oops: await self._handle_oops(users) self.last_update = time() - if rfid_value != self.rfid_value: + # Handle always-enabled machines - track RFID but maintain always-on state + if ( + self.machine.always_enabled + and not self.is_oopsed + and not self.is_locked_out + ): + self.relay_desired_state = True + self.display_text = self.ALWAYS_ON_DISPLAY_TEXT + self.status_led_rgb = (0.0, 1.0, 0.0) + self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + self.last_update = time() + # Track RFID changes for logging/auditing purposes + if rfid_value != self.rfid_value: + await self._handle_rfid_tracking_always_enabled(users, rfid_value) + elif rfid_value != self.rfid_value: if rfid_value is None: await self._handle_rfid_remove() else: @@ -553,6 +601,52 @@ async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None f"UNAUTHORIZED user {user.full_name}" ) + async def _handle_rfid_tracking_always_enabled( + self, users: UsersConfig, rfid_value: Optional[str] + ) -> None: + """Track RFID changes for always-enabled machines without changing state. + + This method logs RFID insertions and removals for auditing purposes while + maintaining the always-on state of the machine. + """ + # locking handled in update() + if rfid_value is None: + # RFID removed + logging.getLogger("AUTH").info( + "RFID removed on always-enabled machine %s (was %s); session duration %d seconds", + self.machine.name, + self.current_user.full_name if self.current_user else self.rfid_value, + ( + time() - cast(float, self.rfid_present_since) + if self.rfid_present_since + else 0 + ), + ) + self.rfid_value = None + self.rfid_present_since = None + self.current_user = None + # State remains always-on (relay/display/LED not changed) + else: + # RFID inserted + self.rfid_present_since = time() + self.rfid_value = rfid_value + user: Optional[User] = users.users_by_fob.get(rfid_value) + if user: + self.current_user = user + logging.getLogger("AUTH").info( + "RFID inserted on always-enabled machine %s by %s (%s)", + self.machine.name, + user.full_name, + rfid_value, + ) + else: + logging.getLogger("AUTH").warning( + "RFID inserted on always-enabled machine %s by unknown fob %s", + self.machine.name, + rfid_value, + ) + # State remains always-on (relay/display/LED not changed) + async def _user_is_authorized( self, user: User, slack: Optional["SlackHandler"] = None ) -> bool: diff --git a/tests/fixtures/machines.json b/tests/fixtures/machines.json index 060af8d..1149cae 100644 --- a/tests/fixtures/machines.json +++ b/tests/fixtures/machines.json @@ -17,5 +17,9 @@ "esp32test": { "authorizations_or": ["Metal Lathe"], "unauthorized_warn_only": true + }, + "always-on-machine": { + "authorizations_or": ["Not used for authentication"], + "always_enabled": true } } diff --git a/tests/models/test_machine.py b/tests/models/test_machine.py index ee946f7..b410cfd 100644 --- a/tests/models/test_machine.py +++ b/tests/models/test_machine.py @@ -31,8 +31,8 @@ def test_default_config(self, fixtures_path: str, tmp_path: Path) -> None: os.chdir(tmp_path) with patch(f"{pbm}.MachineState", autospec=True): cls: MachinesConfig = MachinesConfig() - assert len(cls.machines) == 5 - assert len(cls.machines_by_name) == 5 + assert len(cls.machines) == 6 + assert len(cls.machines_by_name) == 6 assert cls.load_time == 1689477248.0 @freeze_time("2023-07-16 03:14:08", tz_offset=0) @@ -70,6 +70,7 @@ def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None: "name": "metal-mill", "authorizations_or": ["Metal Mill"], "unauthorized_warn_only": False, + "always_enabled": False, } assert cls.load_time == 1689477248.0 @@ -117,6 +118,7 @@ def test_happy_path(self) -> None: "name": "mName", "authorizations_or": ["Foo", "Bar"], "unauthorized_warn_only": False, + "always_enabled": False, } def test_unauth_warn(self) -> None: @@ -136,4 +138,5 @@ def test_unauth_warn(self) -> None: "name": "mName", "authorizations_or": ["Foo", "Bar"], "unauthorized_warn_only": True, + "always_enabled": False, } diff --git a/tests/test_slack_handler.py b/tests/test_slack_handler.py index aa850e5..bd019d5 100644 --- a/tests/test_slack_handler.py +++ b/tests/test_slack_handler.py @@ -263,6 +263,7 @@ async def test_handle_command_status_admin_channel(self, tmp_path) -> None: say = AsyncMock() await self.cls.handle_command(msg, say) expected = ( + "always-on-machine: Idle \n" "esp32test: Idle \n" "hammer: Idle (last contact a minute ago; last update a minute ago;" " uptime 2 minutes)\n" @@ -323,6 +324,7 @@ async def test_handle_command_status_oops_channel(self, tmp_path) -> None: say = AsyncMock() await self.cls.handle_command(msg, say) expected = ( + "always-on-machine: Idle \n" "esp32test: Idle \n" "hammer: Idle (last contact a minute ago; " "last update a minute ago; uptime 2 minutes)\n" diff --git a/tests/views/test_machine_always_enabled.py b/tests/views/test_machine_always_enabled.py new file mode 100644 index 0000000..2ab015f --- /dev/null +++ b/tests/views/test_machine_always_enabled.py @@ -0,0 +1,379 @@ +"""Tests for always-enabled machines.""" + +from pathlib import Path +from unittest.mock import patch + +from freezegun import freeze_time +from quart import Quart +from quart import Response +from quart.typing import TestClientProtocol + +from dm_mac.models.machine import Machine +from dm_mac.models.machine import MachineState + +from .quart_test_helpers import app_and_client + + +class TestAlwaysEnabledMachine: + """Tests for always-enabled machine functionality.""" + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_basic(self, tmp_path: Path) -> None: + """Test always-enabled machine shows 'Always On' with relay on.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + # send request + mname: str = "always-on-machine" + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": True, + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + m: Machine = app.config["MACHINES"].machines_by_name[mname] + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.ALWAYS_ON_DISPLAY_TEXT + assert ms.relay_desired_state is True + assert ms.status_led_rgb == (0.0, 1.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_oopsed(self, tmp_path: Path) -> None: + """Test always-enabled machine exhibits correct Oops behavior.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + + # First, machine should be in always-on state + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + assert json_response["relay"] is True + assert json_response["display"] == MachineState.ALWAYS_ON_DISPLAY_TEXT + + # Oops the machine + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": True, + "rfid_value": "", + "uptime": 13.5, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + assert json_response == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + + # Release oops button (oops=false) - machine stays oopsed + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 14.7, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + # Machine stays oopsed when button is released + assert json_response == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + + # Un-oops via API endpoint + response = await client.delete(f"/api/machine/oops/{mname}") + assert response.status_code == 200 + + # Now machine should return to always-on state + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 15.9, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + # After un-oops, always-enabled machine returns to always-on state + assert json_response == { + "relay": True, + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_ignores_rfid_insert(self, tmp_path: Path) -> None: + """Test always-enabled machine tracks RFID but maintains always-on state.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + + # Insert an RFID card (authorized user) + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "8114346998", # Ashley Williams from users.json + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # Machine should still show "Always On", not a welcome message + assert response.status_code == 200 + json_response = await response.json + assert json_response == { + "relay": True, + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, # NOT "Welcome, " + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # Verify RFID value is tracked for auditing + assert m.state.rfid_value == "8114346998" + assert m.state.current_user is not None + assert m.state.current_user.full_name == "Ashley Williams" + assert m.state.rfid_present_since == 1689477248.0 + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_ignores_rfid_remove(self, tmp_path: Path) -> None: + """Test always-enabled machine tracks RFID removal but maintains always-on state.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + + # Insert an RFID card first + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "8114346998", # Ashley Williams + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + assert json_response["relay"] is True + # Verify RFID was tracked + assert m.state.rfid_value == "8114346998" + assert m.state.current_user is not None + + # Remove the RFID card + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", # Empty = card removed + "uptime": 13.5, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # Machine should still be on with "Always On" display + assert response.status_code == 200 + json_response = await response.json + assert json_response == { + "relay": True, # Still on! + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, # Still "Always On" + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # Verify RFID removal was tracked + assert m.state.rfid_value is None + assert m.state.current_user is None + assert m.state.rfid_present_since is None + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_first_contact(self, tmp_path: Path) -> None: + """Test always-enabled machine is immediately enabled on first contact.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + + # First contact - no RFID, no oops, brand new machine + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 1.0, # Just started + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # Machine should be immediately enabled + assert response.status_code == 200 + json_response = await response.json + assert json_response == { + "relay": True, # On immediately! + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_unlock(self, tmp_path: Path) -> None: + """Test always-enabled machine restores always-on state after unlock.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + + # Lock out the machine + await client.post(f"/api/machine/locked_out/{mname}") + assert m.state.is_locked_out is True + + # Unlock the machine + response: Response = await client.delete(f"/api/machine/locked_out/{mname}") + assert response.status_code == 200 + + # Machine should return to always-on state + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + assert json_response == { + "relay": True, + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_always_enabled_reboot(self, tmp_path: Path) -> None: + """Test always-enabled machine restores always-on state after reboot.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + + # First update to establish baseline + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 100.0, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + + # Simulate reboot by sending lower uptime + response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 1.0, # Lower uptime = reboot + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + assert response.status_code == 200 + json_response = await response.json + # After reboot, always-enabled machine should be back to always-on state + assert json_response == { + "relay": True, + "display": MachineState.ALWAYS_ON_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } diff --git a/tests/views/test_prometheus.py b/tests/views/test_prometheus.py index ac620e1..4113eb1 100644 --- a/tests/views/test_prometheus.py +++ b/tests/views/test_prometheus.py @@ -114,6 +114,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_relay_state{machine_name="permissive-lathe"} 0.0 machine_relay_state{machine_name="restrictive-lathe"} 0.0 machine_relay_state{machine_name="esp32test"} 0.0 + machine_relay_state{machine_name="always-on-machine"} 0.0 # HELP machine_oops_state The Oops state of the machine # TYPE machine_oops_state gauge machine_oops_state{machine_name="metal-mill"} 0.0 @@ -121,6 +122,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_oops_state{machine_name="permissive-lathe"} 0.0 machine_oops_state{machine_name="restrictive-lathe"} 0.0 machine_oops_state{machine_name="esp32test"} 0.0 + machine_oops_state{machine_name="always-on-machine"} 0.0 # HELP machine_lockout_state The lockout state of the machine # TYPE machine_lockout_state gauge machine_lockout_state{machine_name="metal-mill"} 0.0 @@ -128,6 +130,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_lockout_state{machine_name="permissive-lathe"} 1.0 machine_lockout_state{machine_name="restrictive-lathe"} 0.0 machine_lockout_state{machine_name="esp32test"} 0.0 + machine_lockout_state{machine_name="always-on-machine"} 0.0 # HELP machine_unauth_warn_only_state The unauthorized_warn_only state of the machine # TYPE machine_unauth_warn_only_state gauge machine_unauth_warn_only_state{machine_name="metal-mill"} 0.0 @@ -135,6 +138,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_unauth_warn_only_state{machine_name="permissive-lathe"} 1.0 machine_unauth_warn_only_state{machine_name="restrictive-lathe"} 0.0 machine_unauth_warn_only_state{machine_name="esp32test"} 1.0 + machine_unauth_warn_only_state{machine_name="always-on-machine"} 0.0 # HELP machine_last_checkin_timestamp The last checkin timestamp for the machine # TYPE machine_last_checkin_timestamp gauge machine_last_checkin_timestamp{machine_name="metal-mill"} 1.689477238e+09 @@ -142,6 +146,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_last_checkin_timestamp{machine_name="permissive-lathe"} 0.0 machine_last_checkin_timestamp{machine_name="restrictive-lathe"} 0.0 machine_last_checkin_timestamp{machine_name="esp32test"} 0.0 + machine_last_checkin_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_last_update_timestamp The last update timestamp of the machine # TYPE machine_last_update_timestamp gauge machine_last_update_timestamp{machine_name="metal-mill"} 1.689477218e+09 @@ -149,6 +154,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_last_update_timestamp{machine_name="permissive-lathe"} 0.0 machine_last_update_timestamp{machine_name="restrictive-lathe"} 0.0 machine_last_update_timestamp{machine_name="esp32test"} 0.0 + machine_last_update_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_rfid_present Whether a RFID fob is present in the machine # TYPE machine_rfid_present gauge machine_rfid_present{machine_name="metal-mill"} 1.0 @@ -156,6 +162,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_rfid_present{machine_name="permissive-lathe"} 0.0 machine_rfid_present{machine_name="restrictive-lathe"} 0.0 machine_rfid_present{machine_name="esp32test"} 0.0 + machine_rfid_present{machine_name="always-on-machine"} 0.0 # HELP machine_rfid_present_since_timestamp The timestamp since the RFID was inserter into the machine # TYPE machine_rfid_present_since_timestamp gauge machine_rfid_present_since_timestamp{machine_name="metal-mill"} 1.689477218e+09 @@ -163,6 +170,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_rfid_present_since_timestamp{machine_name="permissive-lathe"} 0.0 machine_rfid_present_since_timestamp{machine_name="restrictive-lathe"} 0.0 machine_rfid_present_since_timestamp{machine_name="esp32test"} 0.0 + machine_rfid_present_since_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_current_amps The amperage being used by the machine if applicable # TYPE machine_current_amps gauge machine_current_amps{machine_name="metal-mill"} 0.0 @@ -170,6 +178,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_current_amps{machine_name="permissive-lathe"} 0.0 machine_current_amps{machine_name="restrictive-lathe"} 0.0 machine_current_amps{machine_name="esp32test"} 0.0 + machine_current_amps{machine_name="always-on-machine"} 0.0 # HELP machine_known_user Whether a known user RFID is inserted into the machine # TYPE machine_known_user gauge machine_known_user{machine_name="metal-mill"} 1.0 @@ -177,6 +186,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_known_user{machine_name="permissive-lathe"} 0.0 machine_known_user{machine_name="restrictive-lathe"} 0.0 machine_known_user{machine_name="esp32test"} 0.0 + machine_known_user{machine_name="always-on-machine"} 0.0 # HELP machine_uptime_seconds The machine uptime seconds # TYPE machine_uptime_seconds gauge machine_uptime_seconds{machine_name="metal-mill"} 123.0 @@ -184,6 +194,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_uptime_seconds{machine_name="permissive-lathe"} 0.0 machine_uptime_seconds{machine_name="restrictive-lathe"} 0.0 machine_uptime_seconds{machine_name="esp32test"} 0.0 + machine_uptime_seconds{machine_name="always-on-machine"} 0.0 # HELP machine_wifi_signal_db The machine WiFi signal in dB # TYPE machine_wifi_signal_db gauge machine_wifi_signal_db{machine_name="metal-mill"} 35.0 @@ -191,6 +202,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_wifi_signal_db{machine_name="permissive-lathe"} 0.0 machine_wifi_signal_db{machine_name="restrictive-lathe"} 0.0 machine_wifi_signal_db{machine_name="esp32test"} 0.0 + machine_wifi_signal_db{machine_name="always-on-machine"} 0.0 # HELP machine_wifi_signal_percent The machine WiFi signal in percent # TYPE machine_wifi_signal_percent gauge machine_wifi_signal_percent{machine_name="metal-mill"} 90.0 @@ -198,6 +210,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_wifi_signal_percent{machine_name="permissive-lathe"} 0.0 machine_wifi_signal_percent{machine_name="restrictive-lathe"} 0.0 machine_wifi_signal_percent{machine_name="esp32test"} 0.0 + machine_wifi_signal_percent{machine_name="always-on-machine"} 0.0 # HELP machine_esp_temperature_c The machine ESP32 internal temperature in °C # TYPE machine_esp_temperature_c gauge machine_esp_temperature_c{machine_name="metal-mill"} 102.0 @@ -205,6 +218,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_esp_temperature_c{machine_name="permissive-lathe"} 0.0 machine_esp_temperature_c{machine_name="restrictive-lathe"} 0.0 machine_esp_temperature_c{machine_name="esp32test"} 0.0 + machine_esp_temperature_c{machine_name="always-on-machine"} 0.0 # HELP machine_status_led The machine status LED state # TYPE machine_status_led gauge machine_status_led{led_attribute="red",machine_name="metal-mill"} 1.0 @@ -227,6 +241,10 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="blue",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="brightness",machine_name="esp32test"} 0.0 + machine_status_led{led_attribute="red",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="green",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="blue",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="brightness",machine_name="always-on-machine"} 0.0 """ # noqa: E501 ) assert custom_metrics == expected @@ -270,6 +288,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_relay_state{machine_name="permissive-lathe"} 0.0 machine_relay_state{machine_name="restrictive-lathe"} 0.0 machine_relay_state{machine_name="esp32test"} 0.0 + machine_relay_state{machine_name="always-on-machine"} 0.0 # HELP machine_oops_state The Oops state of the machine # TYPE machine_oops_state gauge machine_oops_state{machine_name="metal-mill"} 0.0 @@ -277,6 +296,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_oops_state{machine_name="permissive-lathe"} 0.0 machine_oops_state{machine_name="restrictive-lathe"} 0.0 machine_oops_state{machine_name="esp32test"} 0.0 + machine_oops_state{machine_name="always-on-machine"} 0.0 # HELP machine_lockout_state The lockout state of the machine # TYPE machine_lockout_state gauge machine_lockout_state{machine_name="metal-mill"} 0.0 @@ -284,6 +304,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_lockout_state{machine_name="permissive-lathe"} 0.0 machine_lockout_state{machine_name="restrictive-lathe"} 0.0 machine_lockout_state{machine_name="esp32test"} 0.0 + machine_lockout_state{machine_name="always-on-machine"} 0.0 # HELP machine_unauth_warn_only_state The unauthorized_warn_only state of the machine # TYPE machine_unauth_warn_only_state gauge machine_unauth_warn_only_state{machine_name="metal-mill"} 0.0 @@ -291,6 +312,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_unauth_warn_only_state{machine_name="permissive-lathe"} 1.0 machine_unauth_warn_only_state{machine_name="restrictive-lathe"} 0.0 machine_unauth_warn_only_state{machine_name="esp32test"} 1.0 + machine_unauth_warn_only_state{machine_name="always-on-machine"} 0.0 # HELP machine_last_checkin_timestamp The last checkin timestamp for the machine # TYPE machine_last_checkin_timestamp gauge machine_last_checkin_timestamp{machine_name="metal-mill"} 0.0 @@ -298,6 +320,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_last_checkin_timestamp{machine_name="permissive-lathe"} 0.0 machine_last_checkin_timestamp{machine_name="restrictive-lathe"} 0.0 machine_last_checkin_timestamp{machine_name="esp32test"} 0.0 + machine_last_checkin_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_last_update_timestamp The last update timestamp of the machine # TYPE machine_last_update_timestamp gauge machine_last_update_timestamp{machine_name="metal-mill"} 0.0 @@ -305,6 +328,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_last_update_timestamp{machine_name="permissive-lathe"} 0.0 machine_last_update_timestamp{machine_name="restrictive-lathe"} 0.0 machine_last_update_timestamp{machine_name="esp32test"} 0.0 + machine_last_update_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_rfid_present Whether a RFID fob is present in the machine # TYPE machine_rfid_present gauge machine_rfid_present{machine_name="metal-mill"} 0.0 @@ -312,6 +336,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_rfid_present{machine_name="permissive-lathe"} 0.0 machine_rfid_present{machine_name="restrictive-lathe"} 0.0 machine_rfid_present{machine_name="esp32test"} 0.0 + machine_rfid_present{machine_name="always-on-machine"} 0.0 # HELP machine_rfid_present_since_timestamp The timestamp since the RFID was inserter into the machine # TYPE machine_rfid_present_since_timestamp gauge machine_rfid_present_since_timestamp{machine_name="metal-mill"} 0.0 @@ -319,6 +344,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_rfid_present_since_timestamp{machine_name="permissive-lathe"} 0.0 machine_rfid_present_since_timestamp{machine_name="restrictive-lathe"} 0.0 machine_rfid_present_since_timestamp{machine_name="esp32test"} 0.0 + machine_rfid_present_since_timestamp{machine_name="always-on-machine"} 0.0 # HELP machine_current_amps The amperage being used by the machine if applicable # TYPE machine_current_amps gauge machine_current_amps{machine_name="metal-mill"} 0.0 @@ -326,6 +352,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_current_amps{machine_name="permissive-lathe"} 0.0 machine_current_amps{machine_name="restrictive-lathe"} 0.0 machine_current_amps{machine_name="esp32test"} 0.0 + machine_current_amps{machine_name="always-on-machine"} 0.0 # HELP machine_known_user Whether a known user RFID is inserted into the machine # TYPE machine_known_user gauge machine_known_user{machine_name="metal-mill"} 0.0 @@ -333,6 +360,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_known_user{machine_name="permissive-lathe"} 0.0 machine_known_user{machine_name="restrictive-lathe"} 0.0 machine_known_user{machine_name="esp32test"} 0.0 + machine_known_user{machine_name="always-on-machine"} 0.0 # HELP machine_uptime_seconds The machine uptime seconds # TYPE machine_uptime_seconds gauge machine_uptime_seconds{machine_name="metal-mill"} 0.0 @@ -340,6 +368,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_uptime_seconds{machine_name="permissive-lathe"} 0.0 machine_uptime_seconds{machine_name="restrictive-lathe"} 0.0 machine_uptime_seconds{machine_name="esp32test"} 0.0 + machine_uptime_seconds{machine_name="always-on-machine"} 0.0 # HELP machine_wifi_signal_db The machine WiFi signal in dB # TYPE machine_wifi_signal_db gauge machine_wifi_signal_db{machine_name="metal-mill"} 0.0 @@ -347,6 +376,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_wifi_signal_db{machine_name="permissive-lathe"} 0.0 machine_wifi_signal_db{machine_name="restrictive-lathe"} 0.0 machine_wifi_signal_db{machine_name="esp32test"} 0.0 + machine_wifi_signal_db{machine_name="always-on-machine"} 0.0 # HELP machine_wifi_signal_percent The machine WiFi signal in percent # TYPE machine_wifi_signal_percent gauge machine_wifi_signal_percent{machine_name="metal-mill"} 0.0 @@ -354,6 +384,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_wifi_signal_percent{machine_name="permissive-lathe"} 0.0 machine_wifi_signal_percent{machine_name="restrictive-lathe"} 0.0 machine_wifi_signal_percent{machine_name="esp32test"} 0.0 + machine_wifi_signal_percent{machine_name="always-on-machine"} 0.0 # HELP machine_esp_temperature_c The machine ESP32 internal temperature in °C # TYPE machine_esp_temperature_c gauge machine_esp_temperature_c{machine_name="metal-mill"} 0.0 @@ -361,6 +392,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_esp_temperature_c{machine_name="permissive-lathe"} 0.0 machine_esp_temperature_c{machine_name="restrictive-lathe"} 0.0 machine_esp_temperature_c{machine_name="esp32test"} 0.0 + machine_esp_temperature_c{machine_name="always-on-machine"} 0.0 # HELP machine_status_led The machine status LED state # TYPE machine_status_led gauge machine_status_led{led_attribute="red",machine_name="metal-mill"} 0.0 @@ -383,6 +415,10 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="blue",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="brightness",machine_name="esp32test"} 0.0 + machine_status_led{led_attribute="red",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="green",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="blue",machine_name="always-on-machine"} 0.0 + machine_status_led{led_attribute="brightness",machine_name="always-on-machine"} 0.0 """ # noqa: E501 ) assert custom_metrics == expected