Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<machine_name>`: Lock out a machine
- `POST /machine/unlock/<machine_name>`: 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.
18 changes: 18 additions & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
193 changes: 193 additions & 0 deletions docs/features/completed/always-enabled-machine.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading