From e8ccbff50a56b72402e93b09699ff58a94abde6d Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:21:33 -0500 Subject: [PATCH 01/14] docs: add CLAUDE.md for Claude Code guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for Claude Code to assist with development, including common commands, architecture overview, and key implementation details. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..720af64 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,179 @@ +# 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`) +- 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 From 45badfab166dfc75b7493a7438644855af94272c Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:32:01 -0500 Subject: [PATCH 02/14] simple feature development pattern --- docs/features/README.md | 18 ++++++++++++++++++ docs/features/completed/.gitkeep | 0 2 files changed, 18 insertions(+) create mode 100644 docs/features/README.md create mode 100644 docs/features/completed/.gitkeep 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 From d72b436e70aca5938e7d75cb9fb2f957aa9757d5 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:37:09 -0500 Subject: [PATCH 03/14] add m12-8 pinout diagram --- docs/source/hardware.rst | 3 +++ hardware/v1_mcu/m12-8pinout.png | Bin 0 -> 46750 bytes 2 files changed, 3 insertions(+) create mode 100644 hardware/v1_mcu/m12-8pinout.png 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 y9%8XECuaLbrMUfpcGqU$i_R8M$#%pA+B!sN2|Mi^T z=YP(p<9K^L@AKUEHNNAzg{Y~@6I`Xaih+SapzuiM2?hqHJ^cSqJRJBh)46#oe8F^n zBL5I`vX{djzQMMTQjx;IsEE3DYJ3@akLjZ)Di|1EtQZ)t-e6$-gO^_Y#K3Un!NB-s zgn=QFh=D=nkW#NM27iHLq9iYaae@5%sVOfOUb*7%NXHoi;~E+A2NNSXg&JPObx}}} z#a+KfOu$0eF*_L#AA+GEBc)xSoi6W8x!xxixU-%74(ytoTE=QIr=qo8ckv)tEkEYv1vtc zP-|b#mWG<>UJ+!oUBTFPCM1-?(AfI1?cK8GJIhLU->JjrbffAk2&}gb9y2 zs4a)-#{Ab#hc}BmdSgqIGas&>jibe_!x5BXJ+t8d?%dp_#Yb&046GY<9OSf#P2 z$|CGCTCz=M&9$nupA+Xx!cWf4u%ZcYuv+4{Qo7^DE5>WT)o5LLU$2&$WWf96U^C^u zbC4x}Xs(q9*OFI#Wk}e4rYe)LX%8CPy^(OITgH#MrCD z>5rwQFV&aoax6O~ADRmWT%%&7^gG<`k0+tJ%wYHt^>)|6J&>3;UY3$1#*XP_ zDbNgc5sApP$}jY?LVtIlst;QT$+c36=N%_FFSn(wHtpgu{fEz&6yD#cAFfSJWjf2H z7f$*2@bD6o0$*}rjB?J;Fqsk8&2L@Pw^+D>c(ENnP2Ips802tV`1p~@mCUEMU%{Q6=#fUUOjQ<(beHs0$Ao(A!}Z`(~R}A3s?8`8coWfQDarKCyFOpX4HI+OOD=5ikMh7ENR(K-_2Ps zIB1$*MMmKv-nbR5j5V(zX7+u6c*d7RN`%Uxs#;eCJu^$Yg(=9AW!ozg zM3L`GaFqY{?nivb*#4L|xf>m1SN{F;;;wz|B&2>P&Z#j`^N#Cfo6%N&0v7cS9+%8f zwg*j3r3-FJhdB?b>2BJVQJ@_f2Bt5+SI!~Ee*J3haqD)AO+oeIjv5`|rKg>9D}^UG zH=~uALzpwv@D$<|a$+tC7O^RmO0{5J@_S%88)S!XGG98hO=B%BajI8hc^e8c^58e`gccQIpBQ};rO7?kb z58rycl@oK<*Wco$+I{K7=*(NT)E9#_*T90hD<}=|D z?s0Lk-xwWt+iFT=^2txk>m8Y+N;D1q*Vpdy)qkyHF8CpN8ppsE!$vl6%_y05M`p3oF>jQi)ivUfLXuRL zT()aT{hfT)hxqs>Po50)#@;TOQyTAH-#do9xf!QO@JORyzt+v)N(IG>Kc=%B!^Yf4 zT3C2Pku;P;^yAJ$+K29ZZ}2-fPTU<+i{*8`pEu+T9Wbbgl zlix|Hv82#xWBfzbcWq(s7~>IZtW1_VX>Ydqch}VLY&P5WiL@558Dva7>KJuZ@ioztc*mv~ZJT)^!&eDdIwhb?6`y!F7v%`J`lVt>RP^`$@l(7|+U6+xnU3_?h zs-2M`sykW*nv|E@J4ML7hQr?#TMcZ?ir+BmhO#_HTvA`nMX~g=kX=~}!LDRcfAVC} z?8*M$=j;`-Z{Ox82Ql~OC(GSQg|H)!3byo@fb#Gt^AU5B44U7^k8wx)>#pS9wMVR^ zfBzoDBv8pjQ;0DTL4>5w$#YQNCW(qQd>=K(D2e88G~llA_)#y|?hxL{7V@(`Xn2iQ zZS(iOmqN~y>?aDld)NM)oKXk8E-dWGRM%9IjixY@ZKLj3mgl?Y9&xC}ob(9d6P`Mw zurlWCK}b7Im3E%J@m^M<^E-_0KFHlyPQws3rWw$XJz)Z1m~tjYnpY$|fow z#gLVibt!#VUL;j6+Wf^(O33iq(w~L)a7Kx=++0@qgXtz~s$fF;%*@Jw&M-<1HSKqW zg()JQG19?AsM*=b><#K%1^dU`o*Bfb!`K``Q*^Z?v4>O3{Br#g%9>Xhf6citxX=1#m z!bk3hhdT;w;2i;|=V6A}?rvxjudj#-Dw)S6Fm2YrOo4g+Py=?>QJrK^x5jC)BLvfx zFp^}5^yAk|W*QorL|#)7j2({v89oIAo}SgkBAM zPw`fbjVOKiv9T0ph=Axtw4}ppg#{2$k+gQTuU$k2$5&rW zO-(_R?(Xg;!oyd)y2_ZU!;U_F_AFsf?6uMRuK`$*boakPe)L|$NxJXM8W%_LJ@UG= zw>)lH(*X?>WGEjfivc8-U8}3UHXBz{L`dU|>nm(Lo{pNoumY~Xwv9T{m&nXI}$Jv;O|o^t3*4OvttTUox*!S6}s z6rR^IVnYpsgVaF_`1#Ft;Wc4xdCdzSO7B*N53hA^@2RV)J$m+xDoGhfAtv>&1cW?m zEOr1Ut8!w`_W}(-EIpMn{4_UdB4T>AtEfyJmAFP-2m8*(1^yNvP(^j(t-Mv!ih~%? z)N1Ef5o5Tl5Tn2v3#CLvRCJV|URM46t&s4H2BA%__j@r4ubstjeQ9dTI-eI_MVtG%y-xHt(lX|U<;t|vuLo-`s@*?riq2fSIin( zSlq^dkI&1^4LalG{Cn`1Nr^Ou!y<+ehCF~%}l30H~ok?g;=QXHGx$U? z57lAf7&9sGL|a=1QnW%lMOn`L*Y^U;!cg>vYk*I$S&39O%miX$Q50oGyv$GQ)WfRTtsL6~9%%Wo}81g-J zXQ&VzFQ3i>bnwCTn#xF(&o zZFMDaW!bigOM74C$YisxXoK4}D>WWIUgGrJM$qe+xNl|(Uug-ee&1x2iZTCQyfdyD z7oOaBcJ@YO@-99m;z(0bl`dyfhXuInuBd(pcKMk_o0naBL~(%LF{{0&P#++x;C zngxen`@`y=lpJ}DRRA~ z&XV|F?{pE>hqg;zFzhTkEL5+-RQl!tVW&bHi4$a+& zS7z&nD-{;#WSaJp3tp)}N>Q;+74^Lr`2UgH!O43TP*5PfyRTI#O zhGzfvt?cLO>d~>Wx7}I9e@;@xY#Tx=v$6uoAU^DVYrjxVe_dGcF7r)h4`9rgUNati z`l#-}7D>$oj3(2oI9a6>VLHmD`>{r(G+Un@S6i8x zc8~}=5Ewynja}#3o_T+A$t?T2OY-GI8*1M* znq0+#T=?=u5UKqSyafZ892Vcmig$%ym)6iAL*+h}NAn+S0(G{2dgHwr#rZ@>YkEoJ zXM!&pzw8ndzupf_S7zbJiWN8r%_JraX6LxWLPrRhgNGd$PAe3jbk8EwDt=+yg(Hfm^l#?F9FxaC*IXR~G-kFrspxVIrx$kw~uQ5)It>euVvf7pS zk5&@Xk%a{x5}v&Wx`J|nCCtgoqmmZl!B>*5x$0j$m&#wDr%5i+v+ z3o~B87={XZePi<$L)8-!bA_SEttvicXylMglIYJiMk9CL+CT84Q?JZU&RjGB++NZh znPE+{zrWAV&#!G@B{p>Kdot1DFH!Yk(8-KcHaqO@a`* z%;LEQS)7&@ITaNN{_|z4;jI}!jPKL7?AGeCrJ7XaUXDDmeBkvR#8&&v9zN&QpivVEDSuult>B>Ei4?`n6_m+eT@Mmf~D&EZC_tsgnkBi zs)iOl;O&cCGW#Cx>R}#c3s9&q_sJ8zhag^6vW&v!cBIQFh?Q}lKWe$)+H+el;V*ZP zzHRz5-sVPVPmG6p0SCH#8fToLECHJJZtz$LA2+oN%U2JMpyBPCvFfQqfeQ}Qys0?q zGSMY&RaWhi(G*IV#KnJ~i%KhzW4|f3xnUk*#TV^>b>bEjI&(boU2`oNc&CPzCdwuB zol{27)luyamK?tZJSjSz<)#J4vvLF~tsBcoOq(ffcAi{xZ{A&5#44U9RGr45EB>X| z&J)zQC4-{Lu^^&w%>B8{qcKyiUCO5D(UiHAnpYWEmt)ge!P1Wxc!w(DcSh#Rb(>+w zHscu#@fM;ov^DQP8{5ECC(B{au_{b;_K)&*RI~*JEHNx(csRT&?N(Gb`T~|NPlRB0 zv00NWfGW-60Qs}rlt3X#N zY4z*durO+Sb#muBiOPA;UWYBT$tbt^JRWw>iDK+^kZ;yMYGts!l#lD)=Tr7=@X?76 z-BQWRWUZ3C=QC|G?K2pRKOZ)R?+BLil==MGyN*_OjOt!;^m<8sA2lkgPBVr(wL>h(7nzPdpoFRN1SqpC##SyruI3y)~2lggqu?*>i8^H&Lg<$;(>mRi4X5 z3JK87LLa&ta;Ks4@@*xQoJQEV9M#lj9daI0wI?N--8GK0=`1X!#bJp0$~fnB7ib_; z^gG>68P_E-_YT|8WfK8c#u>}1g0#dn0ahpQQ%Iy0mB|D| zJFQ>s;N0DmBYyLH*<7f5x)bj$#^Id5L?s6bjYfD_L0Q>ar05nC`PI6v>CPU#@yY7s zUbCU=_UgoXoYS580fXh{I&NL|R{~z$7UexN>6(5UwqVz~S-rV;9Hee~$DOgRwu?iy zT=(OPyie(9(HQ^;ay)vz9pFuH$uR{DHR{JKnqap&BozM7n&7*!Jix}ULe|ccvK_oD z3~&0dL-~eIUkyWio;p{T;XhXcDFI*2$GJ*1mL5!Wx==O4#q??irr%ym2x)L|+$VOX zs$|3CYIoy(x$so2-XZA_?|$45ru?urW$)}N+Y<~L#S3?I6*V{z#Ko#Mxkdu}6|rW6 zQio?}1~}XD)$a?|@iJfKTDiEoI{y1>MH+$)GK(aWq`EpuPEJlwrH?-6-)q+~Q2e9( zwwivFZf{riUySHo+TzK*hrY8s3M zU>I-%xmL{myZJikI%}8iNP2!DA<+&QVr&+Ldak4cBeu{0t7N(f=U47?z6oh*46vqt zEdq;<0H2|S0xFWv#$+7p5{-Vu)X#^?dz_bgX0vRDS~3Qr0!4j-qiVIc0`?AhwZ*4QNQP!f$8f(1L z7Y99jY3~>a7b0uJBbHmExe5ylLHk?V_XasPxTF5EIs))dPkr#)CDE?k4wqPB6;xA% zrHqYltgNh{XTB0*V{ttHj2G429!W1M7q??j@4{YR3id|l-Ll327KQkoc)8|z&X94M zq5ghwHa*!wzYe8}rhY26U6zk+_OJfAv9UDY7RX`Hn0Wo(3!qhSv!EY>(giTMX4Wgr zv^!E=OGjX_(pj!4%G`nhznLADZ+!geOKWTPJ9p~0D8F#@l2ek$JYog?HS5=ujCu#Z zN~1y9UXw9(RzS!Ut{SX(XJ==MxNowDm^f1wY>V=bfb70LMZ1p<<0-_`3M31DBXSbH)s`?_His%l{^rNn!FF6Jbfw${*idP{X~LlL(U9Z zi0cJNh*8dersNcfv9Z-X42kBim2}_rY&!gRbl21Wpv^Y#y;qX~7*d0?P2N{<0+rLm zCZn_TCe0FQT9jGa*HL+%>p2M+mNqslAk`SBqjy*Jd-o>&t?va~>F-bV@lyRT z^oIFZp_Xzo{}nB*GS3f_(Ko`K%hDgQ;`ERO-fo;&_X^aAPr<)3DzL~EG&EG!DRSNR zx^^a0X5k%NA_lYRFSg1l!qFjQw?(cH+~m}E&|LGp7BIi;#whNSxR&mp1{8tb{fCsR zt3Z(P{Jt0N=~<4TESEG6ozNH$K9IV?Lb%e*%*^L6U$7w6*MI-c21Na8Ox3efsnYWD_+_O&JzaDHJ~<_2C3!V`1hk>&Bb2D16=6CuF-AJez&n zA5=Cjyhi%It=Q#{c-_dHo}Hl|TbC~B{c*2w0$M@HQ=nIou~sjB-sEQGdl^xaL5r6e zH{Iad?KbOoVjIxL1o2#?A;+P#`=0W{nfHl3wiHvUswp*&#c)~<7>@c4ZcwDynWOQd zAHC7YQzZ%OWV>^R8L<(k!&np|M+t)4U&YzaLc@i1;0yAY;v@+{;Q8y0g zIOLR+Po6(l1gZ!>@4Ab=rBLZ|Vk0^JHteo>U*N^K!FI0;Y1AT!h%iwAB?YjIi@wFz zH&>|kpK~8hB`F6%zJX4WvR`7A-0rv8zSQ(<*6mAlVIhbpI}LMZged;H{c+ubq9W1T z_MrhE?+EKL^^%6j1VBNnghmF&66ny*ip!glk6B}{Ke_!tbc}-`iY!D%*y9hA_u-cL z-s&J7Y$j|8x$F&Y?kEV#4C!E~vcU{q@4%W54GY^jIaBqtVhYn^FA3F~<% zt2F;}PeGdrNvK4C8L~4n+Sn68Wb|m7JtV)Vn2QBN2v`?1sVplR#*3rwg2Fqj zK&}OQ(bwOfBwA5gyd>13!NV)4$vQ5 zLb^}Y)p3tlzUE1sSv@V)1BK-VO+=;diJO7SU%tcx z-&Eo&v|e#*pl((^dAU_pScn%O$sEl@8Uo^xPV&c(R(5u|5Rx*?3x6-YgoTAZeT9sb z=}HXQ$GRV{jmi$q_}2f9K^SD!)m?{z4GF1{t8Bcx+&hpeN(CRBOX z+qa~~-hNzZg<>`ETEEqN*tET~#CVYi!y1&F&pQgWiV!a`hGpUKgctvkPE54f6@3)1 za?yw9D1J>X`aj}&2jWiSM@CBzgq_z-qVBm|YUfY+H|I71NK-_-OX_?yXC>A%kpX2B|&s4zYLmeBkLy|EJa%)l4~jo%X$ zm!ycpssF+fIR?di{I{$Iq~KE+<-@0H-*pqD%oviGZ0(E{*FjPe|#r2 z>k)`peTJutRG{t#L2)g2T2Z9rHc*CKqM)FN%r+CUB$bVhi6Mnj2PH?Db?K2JSCA0? zjKjDdb6TxsvQ;T>4M7cy(kCgg>v?r`NePC2NvWdl-^RyR%?yycqqzH&$M`Guox68? zhlkgCqHl^FFUOkkB{`o}x?WPJEc@vjmib7GK_10V$)(o=3#tRa1QKv}!2f*BfA@56 zc(3nn8FXvVRerdjREnQh_Zbh1rwFL`(axhu21AeTQ zmaL9Wl5>9DfcKGf%&n_euO@<*_*&Aa^&dkc)o-Q-won#Qn1TA|0nv4|{@7m;1i<=I zzI86MVw*Mb35EnxymhFUVpA2G#Sjobn*gU2gC zV_+Ww#HAI6VtjGFeKGXdZW&96UWgY{>)L z-$QJO@%#@ixCp4V9=~DJu5jaT*$Ob0q&k&8^GT5;4)Z?RXvODhz$+G|zXu1q5T|=z zuW^^#P78KL(GK@Tm6`!pjbzw@tW-hu@`1Wbm^Cy=L3L}RgG$!16u2zPpd3oooWL3WgS?p{6$9;cw$+ zn};%RUW&bSwZ2qT=f(kzrdC`)09Nkfk3&cXCJw`ndBQ$S)H>e0IPt%*XH`k=V7%C2 ztTJlDDl95Yoi9i&K{`4Uu@UIopq{}>k@XCi@qu_uE}KzU7^=p$OtZZZfH{@u!G$~Z zqNhIBIdc=c<=2|M7JAbfi7U$GCL;~E_s=wc5RZR$Ytfd$|s8o3t`+) zi+=M-!S~AqEo`Z#dvybSv-odevZPSa&rckZ!2JC5xN)b8R_ySHv(H+?obgHI<`1A( zFrscC1oNPDl5gMLMp=*y< zKLUnS#pvT9z;-r!tRn$(Z~)WD++0+0g$YSrps-w+JNrAVpKIPJf*X;YCckGI5}p}& zz6E@EO&?uQQnHffy|sXd*uYt-^yDi!($TYR=Q>+V7P`6Ag|nOA72XBh>*B0o|1r;e zL-d{!<mloGgd3JOAQ%18d$?dRSb*R|hy=D+`J;G;2vFUEfT=*6mhsWEU`_er$3_~=^{Y$Kncaes|^0_C;@$Yd1W>- zE~LcW)UoY%C<)kr+oo>~^^$WOpnDOKZo7n2Yv9E1CqbQpNd&tDQ>eG4NpIKZB=^74X}S??`(zpM{pzN6|S$w6rflWX8` z=(%d#h4GcS?e2~J-J_)_=ZmvL@DOM~mKpXxvA>AOQA*gI4zLV?gJs{fXZgz4~M#uWmD$9zZ;@sy-XKbD2O_kzUkt6xLz{wc--}=*&#!@e*BmU0qtCIiGtAY71)9K)Yh`=983v76~@I-KqQmZQXdY{2!@ z^2PAxch?z(>l_>$W>Ca_cq~Q0j5<7`=T0XTOtdJci_b)>$V+p!=bO(SsO)<++04|( z`5!0vBVZ0u;gFDo+yI7(`&Xkjo{f!dzKho0MY|PXCL0%*o8XYqi;d-36M@mGde^&% zNY&hYUWk<@?MSfk>sKwp2VlP+KiDUSa=P?)9VNEgbCW~&3%)w}sKA+Tzo19UyxBQ^ zp#eR?Y?i*BsbMTfKa7GS`|bA+l@7W>m*K^};XT_aenlmvVeiJ0n}!7VD85;k$pQfg z(FPy;ERqcwOB{J}lfO~ULxJ++_IpB#jof4?<^Cm?zj~K^GtvqC{61e-&}=+S0<5Ynz8Yya(U-1q&* z56$pal5(2R_vMFOl!%FoKwc<(YFb*ZTt&+d1hr@z<$=QLG+Oe4vv}I=e~N33F3l+_ z3X^B>e3P4-i)rn}!nZ@h{Za4b&otkpezh)@ zShKS^!Pq-FgE&kDlx^Uq?NsDyzwt&k%J7I=qQ=hGdeh*nU1>4>qjZdHTSRxood(hwLxra!$CDGS< zLy(`6m-t75e9Z`Ea0=Lo;b>ZW;GWS^vm^$bN)E+u;Iku(<_WDb{ z5rV5ZcG@6Ff*N+ZId{(4;I_F~(ao^*_~P$lc6N5hA1c!xQ}mZ{{q?W3f4N9MF{j31 z=#=)9y}#5Qg;0aJ(^VmxAGOia!Gsl1r;rIcq@7PyTO&Hx(VG7`n6%=9YZ4cpNCCdB z<|$A-|HR8<>!jwcq^%0*MDZ1--3VPqMkWA$$-t7tmt8~W?5L$kI zJ~c(x^w0}ZY?uKyXudexMzZPrXVm#SZoi!{$bkC{Yi|jst&JNu{go6IVRL{1wJ=eJ z@j5Pc?BRG4`85GcN2-5?-HeF%`A(EG1peaT2{*fbW5S`m5_@v4`g_CH-}kH7IXPGA z*3y;+h33rqKCo+QYBKd#3bZ(lz`2TTBFn5_^;^_=^rCKm-2D&Tkpl!so&Mo}aRwQp z%R#o#sB!+X)Rv8-ZL3NjQXP5&=<&z_7eu82HNx-WWDfP?$3THb9^B?YB5zO93SNTb`Lz(cv#6k+Xtn6hWm^6GR zFzZHkOSEaL_4Os6HPN{Rbj!H_(SyIlhR1!+^651;c$V~Ty4-qW#^yaZYY(HWU0rgJ zBFBgR7w*q>b=kSNx;FNo4;T`|fSJpT;-IaS)dEOOT-?n^%oqDD=g*q|=5WKzGhg=d zt7|0{86H^&YeqyoFm4Eou5M^Jj3DPIL>)BZ`N`i{N&9&ccX=Lt-Ok#ll@2A!96u?0dh=fEKga4`H`7ZZGOlKICZbY-38dZ@PHPp#L_X*+X z7u)F|^EsKGgL&83v7%=h8X8M3q5zWGn+1)&V{-_oEWv~L95&3EA8yTvo&IV6;d?j( zL_!`#-`DHMDI{6?44x<@1y&$W#OBW7o0>4jph9lL{zGdj?}-`WCIjxf@wzyt_UEhi zM^NsmV3t+8)*++)g#`<9-Uh{iPR!#yOaUS6CsQ`;=%>7dqNC%Emiv@X*#-ze%BbadUAv6N z#*}Rcu+!fb5?KumACZC@6clu=((ajHUC>q&4CIyBsi1^G-9xGZ61zkar#y&F2@M2K z`_^1SpDjJz-|zec%95>C$)Ob^(a0>FcY=%DzP@-KuFjcaY{wGMlM))qMIi z^3i0+xQ&(;!q{6bPFmKMdt=3p<^#q4v|a*QFIsnmR=nNJycDf(2w?=W8jt&y9wmo1 zqBX(`fR@B}qoe-xKK6@g*lzv^S|Y>>RvyMy)47^I;tq$qI@%&C2D z;<(%(FJ@J7s~7+h>jB_p)HM~M374-?f}Gvk-`}vRu~zoRbAsYSCW}bvEL=#t#twum zdZf`KWCC!lj5^&Du9NuyhIW3u;C2q3N^EuV|C(C!e#!sX#}A*q!Al}v32x)#b*+*c zMDS0%-UiJ8$r_j~I{L9D-ks_`s~{!S_KNOQDdHL#_G^$AKO05JWg*2aKAsZZ#>UQ` z0h6@|w>B`?%D%B$nshBkceOcp#yJ}_e2_uld|!wR7HE$K2D7hv1*o2feL-6tj#U)h z*yMTzVrvG(3>3g`D3ml4@h4zOFzXMbxY$qjzHbw^nWb&JEu&i|#}gf$-e)cUw4fva z5x;m{x^cR1cu{?ib#R2h;#J4W?hy2?%hz<($q|VbeX+Xnd$J+;KZaw_M3X+eR%=eB zc+m1A_nB54^wvE@M4UT6s8MC#2Kx?CVcln36JoRe8DD25Q(Ar06$^G=aJ4mh_g7Jw z)9(phO)Ya&u8Pm{O~wTPLB_o?w^X?{-{SA*XvbM4L%M<-DFY_pPB-Hk?c5R0f0PqM z)gR4s+j~+oQ&W8)KG`A*3whFiTjE&ZYeqSlJ9Vf_yfQ1C9{^|I{+R>_Krp5B;U3u- zgVa-~p})q8bSg|b#}pm}$da4sxOs(d1a18#ZAoOYq}BA!zoI?KF6#E%XfRm-F=t_D z4?NqH$RyB-V1FuI)}J6O9Q@O=N7874nP|SH8(f+IjBmnrul9@%{6^< zEJ$OR(KDNKRWF_{MCLyQYH$2S^Pjieq7!usM}%5L0XXX9cIu{ge@`_)`wcJOdaJUJ^rfO}=zAbF+cr;BP$23c zXY4+I{*09IH$hiaFXA(P&K-h`B4A^B?p*M(*bX)RBKt049!Sw*9)Pn*UrDKU;siwC|C+aN|p`2k!(y)~TNaOa&s{2XXE}Mz^vm zAmgcdkPma7Zktn`U<$oAPx9Pbg>!vE+1@X=Rn$pS&yNMA)L2$3al<(uPyE)_()waQ zH&oNS&k0{u;p|4Ls;DEgE*I#9ivY)%i1DD-_{yHB$J`S0NNGHhIH{6Y0Y_$`c z0~ZD$MXh67M5MP@or;7omDu44Xf9bBB1q51=uhBwmowIXnd(Emaa%~s0SKt{)#pT1 zVBUj8*iYntBn{=R_2P)}A`pfpxIHrbpJij*}gHIBNVST3VfH54k=_asKhe}-op1nPk#?1^e`Dv68ZVZbY^Rq1COfQj6x-MO3_NiFN+Lidq0hY`Y* zT2T5*Q<~V^w`T_--&0@bGJZZ!Rm>3v!wQ5?nXJ)0!OFn_u-(8Y1Kl~)(X<=%DK70a z3C&>^YJ7wZ{fE&2dzXU>81HMAL_sbnLmb@wb!hB~ykg;}(skA0_08#;z1Z~AHx9xB zrcn=8d0Oud?t1S@iwR6EFm9Nk){uvT$=XO5t3jv-pyHAsZS*IJ0ds+$zst7} zybUrB9FRepd(pG9)z`QTMqrSC9~)EG)a-y9_B>h$K86;!R=%{l`T`+>7Mcrj54E%q z!x)VA^y2{w2+CDli6cyygKU3-L&w`SE%*sCAuru8S-^Vy&rXzoN)+t+)5uC=z3(e6 zq=AXOX2t8M$sN-#Z9{{-&F^*pNwp6#)J_>rR63bYWd5JM>)vYlgx-7p+7Vzk~ zi}OP`H@a9T!HwcU>>orE-2XL(l&DkKliO!!XTaIG^W0ctGjehuf;?UW;{fO_uC&yr z^Q!e#^JK!ea9qR&&?S9)uGUYdMqRCw!Hw?PJ_P4pMMVX+5Em-?=eD9_pWm+{*mPdcMEU+U^*LQ)T!YV^ z&q*+-Boy9hcjpAbc#7`M}Qn`v()d-L#Er}`xY9AQv>oCW6&4aYX_h6b>OP}jp@$|ThG1>^Yw zBhq2@mVC$fUfUsO=*s-|@2aMXbRx*i*Z1u5OjeQcWw6jXaX|fbA`EL2NzG(~ISJOs zX}|fg{_%jB28|x{)?O8`MYePAOg})w{2k7MsIm5hl`swoRg8ck> zfLIhT^5H^QOGE~5GT-`92X;dLb#flPq?!jj9X$AmZjlCygV?YB$J`fhfo>dr_opq^ z9))KcaZQEN2hh5pwDilk>%ph$qL&mpQlz(_P?=S~9CL-%0fQis!JfCUS6&AEfe{t3 zwqXne401S^H+!8%%&4_e|2u04GJFJyZk)^t?;n3%vM%k{5_r*-R-pqkSs*`|gG6>b z8s*mkWmJKm<^k24vsYK9djw{4+W?!lArc`BeRWlu_m^6VO{j%uq&TlE47`QTFw1(rD#Pnf#K;2J>%wP20XvFdJid z+G)t+xx1{ykS6_r8m8X0w6s)jIf3#D?UbtVwi|{$x>WyCb{o;Jy?ZLL(0pM>VDwOu zsTKNnNcR%e57UFRnJ>1qa7gwRF`lpvJGw(Slo>W?rQh%z?6nj`-UA3pka%gHZ~Mml zNQU(3pI`~3aiR6y)ipGnOWohOZSq|OYaY<=8UnAPKqUbO5V!c-6l@vj5VdP*-nAF! zCy-=kBdZVkDwTl-LYpy#G)6=#n9a(|Q01XUq<(}oLI3}oqApZwdmsx<4RQQol&ta3 z*K1It2S*7dIKUVSE}0Cs6@G~32-yI{cj|vID=JdBF~xW>(1iLZmnC>Jg60=#{*CuC z+*ov|A10!njtVKoECvR>N(Qt2{Yp<~w;P@sOgYCtOQv7z4B4ER;0aCv!o{Yl;S!M$(oOgTV( zCr+`Wvs3C$9J9DM1K7-#wzjEHy}zwEWx$abbeUoB^OB(FC324*^^nV^b<$mZi-i-2 z`Ldb@L@)5FWHdBTpNFlhCVBYy0>Dzg)YR03clGK+Cnr7=6BEkp!88|Baab9T zLWYDgv3)3)6Ci1Wj_HIB<%`YYPF2ON&{n?t2nGm)YHNjMWMq_&{9=3GK;OoLhaP!Z z{l`OWWZmWSjxBpo%UTm~1YjJCOHmSX%F6iWkN^75BEA{S^-97g%E`&SPe}={t*zDB zIUb-?)w&bs@uv+JdhHwcSx;P;T7x@JHgevg-M-!!3#*2l`}X*^o>u`wShx0O@o_GR z1I7brL;t>ig*dE3gK*r*%N0B3zwbIHsw{{=tZCDwBYj9Boc7VEkuvnCTY?IHWx#vz z&&vGIHjaR|2SANog5s|oPukaOyselZc`r#B-gk}(unAfDJ8>`D8t==^EB&~A>(&hj z5Ve&aXpNMjVIF@U3AT43uK^U>{7>i7vsVD_~ ze&5FxOQp`0@c8HFzZYpqcC$Gt+)uidAFW}+{Lo0f`&Z&x>S1|tc{cKgW$3K(a$-<- zCg4nSOwjZ_SsRO?l#JqlyDAT6H{F&1!+F4L46Hl@LNVSeW?zfH&4l$$B$ZG~N(wqW zc8cn4ei{Pt^CQy_2?^5h`%-|7932n)M}n?H{IrNdZ1^TS`<1zW<2B``-Gn`6e8T!e z>|cyC4#n=9zJC4sT~w4jKvkGmFc$m>mGNg_p*WK{@}k(DH=WL1*A zlVnvwWRD`VP|;Y;%-P*CGOR3x zj(WeCId?{H1XGG*{`~&qM~`TYa#(lo-tD_OmgpL&Wn#hPgXZr))p5X7Rd-bZ+9nTcaL|s{awyVt#bNfv*3;$Gkraa%4s^hbIXT*M zQ~ib0_l@5=vfQ0wcgps$W-cGd@K5seJJj7<-cDov=6Crzs^4K~b5OlD4i56DtE>NT z9b0YS22lsDtqznuG(bhdiJPTfd6jabGYPjAZvs3>;@SNCxzFx$y8JAfT3WojcmJoT zs0flw&CHDD41%8w`T403qapN|oSxQnahbg7*Esg8obH8isY^7s;)bk_ z>xL=*RdtHY_Y2OKAAfUltn|7YqsrS`DH$qTRf?4qQ@fLIU{}At52{AsG;x zbaXkiq5aPOD?U1`r>m=m*b49<6qqQeZU9%?rKF^AjrQNaf1inyQ!%JG&FQnB%4}L? z#)Kd2GkEZba9RP7gjqbPIKm`pY3&fJGLP4mW+VhTpQ)AAO%?xNJmTU%DwjT~NFr#& zq@+Y3&k~(c&+|Vc3A~^wALZuOK+>4oy*F0ZWnxfWQ7~?|X6Nuu7M5$|yCozT1>@*M z?rajdb2Gu>x6WCE8Ggf~u0Yb6w{MU5{261WFSp=YqO0PHa<&=@4SoD6Sy`8A6|5GB zcB-TKHd+o2eEclUMJAF@-zc3`udp&T4T_Cr29DYG=+?Qwr;#;&3X&T#hMHSjvww#o z{);{wKB;)RjnZ~!=;DeCJK?}^8SGITo6DcuGy3vP8}H=}csL|&=?dSCnX}o`As@@< zT^gXjL_$Dh9eAUYjZO;K1QPI#4Z9JGT8XvjD*CSpHTxZMrZ=pA)+PHDIh{#Qg zO=5TCC3pRZzN5^ZVw)Pzv%nqw;e&a7Q&SeM6N;$ivvR7@?R9=IhPV#v63B-sAW5`I z;C~gk|8-T#>o0r1cB$kX6HBK^W?A=jXir>F3{~d^)<2gmahWbSI2c6I)w-c$38j|F zjVa5`s(?3m1q7(+=;&A-OYwWt3|Iy=|8y|`adak=-$tF~dD#kuuAvnM@0 znV(rK2WexAQJuW+6c`_h?I}jvP2X0!^PXEZ4 zIh(1jZS7V&A9$#On1$J+-aciHhvi zrJo{=jg3E)+>%Az8^u0E#O~5&``qd*v*yGtU+YjO2QPQ@;=~1%XCV!icj}Kf2 zbex?9uLWP5b=&Y``?S+FT1^M{f%4>+4#pOIyf*~H>w<=c?D1Y49380;r18upQ(6nG z^6-zRQW}aLR>`)$C~%FSeawa;LvPQ&P8m9{M6Pm8%|S4IyM=|X-*(zb4^3cn%d?s@&pRd-h9BJEkUTf*T+9hPt^NDY>({R*Ih5<2`na;9fLK>}ZbZAj zzTRZtjN%UN0fVvIR5p|q90}VO|B7wN@7@f-=?lfv=Sn9RsM=DhIdGypN=`aDI^N`q zZ5j!}#p-a;1VD)L$s*rI<%!ZL5EhELBNx$Czv}-%=kvk)*6_;l4|OG>qk>1a&)H;n zZfsi(xp(%LW@l3aOQ7wfIhbkkZY2hdD3lFaMn(+Rf`iZQ*yKEZy!~qz4F%)G z>4TLxi?D=a;%13Nx>@z8bTX^7oIICv{E|2`O{s$4fDn(h<&hge22e}$?BDNy&0e7X zV4O-svugB&?;9sC1T&3nv&j0p&*UodJ_=)^V{U)Rqd>UzP>AKw4OI2zdO7y z*5o^=rbZF3F92>A=ao0Hr=ICmZV2snyg3pgPII^k5^flyYJ=M6uDxfrKLlHtQam3xSEk?vD35(3Rxt(M%DFs z#=odebuM34;p5{&L&P`;z+BDQc|51qSK@021>@8Jr_Lf9t#1S^Xr}WRfMZ0IL)4h*wIsJ!KR8&Z|cXZ^(&!7C4_QP9n zyQ!CH++_uck;`42E~igx*xL`8yG)Kxv^Yw0L}??%IG~M!yTe1~N8R74TMEc%H#D}l zsI4jd@$FX*yM>PcBw8ZtA5}7tIOln+78%J z{1@G%(L;x5Y9HYvB3qp%Q(Ms^!j5qnnK6q0=&H6{srs%S&Yea1c(Pl31|UP<6dSf z6$}jx9jm&cLgYBBEvJf4p;M_?vfw!ZNfuB@HhX(}9G747j(_Zx<2H7LAI>#jqPZJ2Kkl~^G{*`Q z-;t1G91~;D==PT3FYKEka_YG`$=bTQ`7hpWhLi5c-$hk+`aYn{_`PLfXmCgwqm>QN z0|7DfQsBNt$FGM3Lqy8Z2hD+~;KAhTSmI!O*LuDhPDU+KPX-@aYL z+gk=-$HdMqm&xjxJASx&Mfn$sJ1)ONr+7p}MA`>BjP!2e1LHLPEcoIf57y8Ag%$JG zt<+)m&W4t6X*t*u*+vKi0Hqz?x4Yc-y!V{=!7xZdn`vTWa|?9YPL%YU^*wzg7@vH+ ziGdxW&Qu6oSH?ZZ&qT82iPe=S65eEJ-r|#q^>E}5(u8iuLUk2j*j}<&o-KWHcTTbX_WlRR6BO; z;NajO;S2IY-0ql|7|D?#=$SFBiGnoW(b5v}FabK9-!Z`brUrLEV_3)>0~B0HrbhgxGyxK zjzgXbl9G~F@jx6;o!Tua=~ z>5!wRr^lK;QY@+aLAB?}W#|K-!r740N>gghfbq=ucs5u#BAO$1)ws)Lp~5FkF5**e zDp#n=-vj=&=|3-JT0G&squ=rQv%*M#%mx~}gVeRqk^KJi$JE+7GA4%U>4~yWB{t== z<$v}k7<}`Ki-|tp3syO3!jHUmbiC*$P~V^5pl~azB=)0Qs9A1^wRq0Zyt@Z4%K#%I zV^>?MatHce<+dzL@I%4Gm8PZu4VuXe6hmrgX0xCl!0qiaOet^yPMI-xiW-{fPz^!$~)n zmd-mmIkmptZGzEFJW}M7=eGj2HJZPan{$xC28w_)Z{j54_YakvmjX@qjO9a66+1OQ++u4zkv=28ca z14ftug+;XLItS^WKznNI=)kj=nxdL;3B3FE-B}2iNZ4jX-#S{gKh5a(t$HcmQ=ZZq zljmvLRP&|#TAFNk#!mPmHU0fnQq=&NFOJo{t|@p_MM0~>CnU5HEgd4;qi4^w4SlwV zZ#zMFvn$XEQBvGd%35V~{-%UyNNPSQDP~MOBU)Tje@0Pf6cxZhsUhKwItRc24i3&` z9n(a$$)T`6CcvE}E2YGS`oGRK>G69NSxByq5 zlrzYj)tg=JW1f~cJMOx?#4N_X83~t}iAgQR7(ojcNA1Yy*^N4_&y5gA@Y-1Em(Sav zm{dox*W1?@TXx{0#ca!>GHxDP7Vre8)~xYmBzgP#DyVB{pq4r6c;Y$g31CWqr2vjP zr;Z?fU^Frl3Y1F&GWMPHb?v2Rfp-ALkUVgJ+>*g!BUTK1UP7FyuVHY=UG@vvK3lg)(MlX39JIT2V@S7yEYfV%mm?ex!O1eHbH%B*=+v*(LI&R>$`o!;D<6=U~ zm?Q^12)~R^i{pOjmo|_*hgyBZVX;O5ZMn1thZ>kWc&;uuVQlq2Tw%E;p$}&T5Y*(| zrV>6m>2f{f0L{L{mQaw|R8&-Uz^mh*XYd=~K#?&;WY4b_%9jK`Yw{%!aFn0l@!C+j z2mAvbTMhqu#cMw+cBb(hH!At(R(tc}d3qnjWp}uQi+^8yy@O#RDaXumEyOPL;TfUJ zs&8yGIxeN5FLC%FqPcTb&By=h5>zmfv0HP&f#bN!P$2W!Rl5_?d|?Nu{7L zQF3DLA00HA7L3nIJ>M_R={{VQw4Tl)MT52xRTEAe@R7!ljr`j$xVuMk@m;fQ`}FAB zc_mE5Q6n;=vt6!XlHc;`-$T<}^Ea)nT2@vthzgrH{d`Ra*OE+6;)Sa7vvj)e^ zf?tUii|XIn*MElDb&xQj5ImL;YLpXfB23kE?m(6>%Wj-ttGontE&k!8)>Nsd5<(~_ zLZ_$Qf&A9F%LR`p+)>psc=G8E5k8@HW7J<&vc4B>Ojw(YFXg^~($yUGv2uhD;7i9%ziDTpApWj2-F6C!7b_A6T zp%jK2B7k{@^|*%t%*Vw2Jk-aW$6*7e!Ke~~QE?QT?MhdMpJVnk6Z4lp&cbmnB!tT4 zg%uSwb?XeL-`7fn<4}Bmt8-n|-Q=@3)Q1KP5@EcmawpwZ)3SpFGS;CywJGIfL~$3 zy3C|`{}Xc&_s<2CLPn`q`#o|_Bev!aZ)z#}49zAc79Vot_W5w%)?nu5rVq2XljRy3 zt6*g-5mLo;<5rZ01)#I;a-=<(P}wnFVe&=L@l0P|ABgQtUtAdgy^U z=xI3U!}bJhi)-7m+wPuOE&+FO5kAjccnY~57?HV(@Wk+zp=-zZ(@mDe6I+m_se@|> z-w*bzX2?^_*IaKr&YlBT2er-+`0yIc3P<6;E2J6WA0%;g^L)m%6;egXlQ6$RQC6Dp+?X(GHQHim~zit_V9g^lfpzxv$Em~ z;99>_t;L9wJvAIxL=&(#SU-3agaG+Lw4SrMqW$^(*X_oK4ru~mffNtAUFZ7wit=q< z$=tbna&-?K!uYTjPCK;v5vZ;Kh^XaRv^}mhdet6*yPV52F&g9S?oK~4G7@uLfK}3> zNEqNG{t{Nr)2IABsW(uX9X?Eg4AMGz4i1G^C;WOF9&~>vnja|cnU&H!Oe^*cmjWTh zsE2iB04Lzul28uR%7o__9Urf?ldPV(pdk{65cVJW+{VU$_`~tSscevh=jZ3WHWyj2@ z6k;00p_Av8_yq+!PYo(wX)xIy^WZpcCXOrEEtiu`=lwf9O6rC@)kN+LNHu?-&Wj2Q40) zl$4|>Fm|Ks;NSlSY?uYUOi~!mw5d;?cL}6 zTg^(guV;tdxN(D!C^#VI`X8S@o@W2xdt8hi(EV<3Pc=1%dnlr`ccak=u#wm^Robk< z$F@ld)dz0C)vH$(IX$HD0_OAo{m%akL|FX9>k#k?=n2%aI(R@GSz2161DQEvuyq^v zc1J3X9Xqs;d@AMzSeoO*7!#auIZ1Mx{!+-gahDP;IG3kS#eiCjER3~)B#i&?2p)hsilWoAK(VmfUi zn#1qfMyOegnzh zF{`R-7q9?OGk>T823q`wnimJZlzM~g53R9FGF#u@9}stWFWaVAkkat)@#GE{J^IWJ zpZ~dU?N?pa-2?CyLL?zb9l8mtJa+mPwOV~=CkZ6U3R%Ir#IB)ks!Wqyb5cn?eY&Jo z6=~;c&x%?akB2Nu0c?!%_Khm#;D}vh&FI~LJ+)K0JPi5D?`?P`1AaofWs=7*`Kg8q?^uEs zfL&>6=>n$~c9co)%f7_=vKAH=f?sU{;f9E+-7|}t1m)^f&D}3z-epV)%3bb*jJHNi zOl=5Bmzv7`ZD{rT$${3p{bv*(1sXw^MGbPYxw#pqrhI09>^J7ypI;xomKtD;paodv z8fXfF{~<%DflweG7QK&lC${Vf`PAEc6A9+Q!-p6G+Rs`yB3{ zL@} z+HC?bnbIoP(39+1si*m-NDPtyARYL4B*CDA2?-B3u8v>Rx)r@k)o90q>S{HFYorsa zf&D2e!{?QzUdO4Zc76GCbc5>)D@0GkYXxqQ(?!|AhCoA*9Aoa@)mIt7Z+%PO4H^kt zCwfY#0MZAZjS4`t1MC!Ch``y>6khfl%)53)6PemsC+T0MpJ`G#(r|wH1q3o-yrJCI z_UJWwoU34u?Ev;IC8KXZGs6o;j+Hg|`W)?>QRzn?-SGQ?aq-B?#(Q0VH<5;4(XWf1 z?@5A7WEoT^%7j$uKRf zDTZ@%($#Uk-Z{%4SdnyA1@;;iP*4F^fBwvYlH9ecUHymmh8FzAXjnowhZoBFOFnsI94 zAQb*yHTpq^3u$^GdQEd^4}?HZNi%z77^$Q5P`a= z7Tuq`dZRU%(eA{_ZnM%(9ji-~IAO6g6CE6P+3kf@PIimUQb@K{UL!686P`QrEh)_> zE^jx5j1LtX0I5tWrMQX@gjJ?4WeVmO)2pLsUN=ruw0+8y5>gGqp0r&@k9I!~Kf%Mq z164!W#o4eLyL%rMi3-8pJ*3928e&fuQ4b`1z(?nPemg>;27mK>qkx?=>+$N{-Vt{1 z0=;AFr>r$R(?@FNx%1`)Z?sH>sb><}e9LK}?ax=^H%q_EdoXjjjm(>Tp1z>k^sf_mNmG}xYSh=Y9~Y4kLNU6A^ehGZ2~4mWq!`e;h(5C} zy+-_dgH)j>CV1$Hz8*E};?_8(n|(-fc;$GmyZQJw5SN;O8U7|&Khi`H;0X;W)a`b~ zJ*0()q@Nzrkb@BLp!de{L{kaDe$c&pJeaF?)(`DUWBEu?0Ahz61&rIuqb4NaE#9y7 z>+^JI8Ff&tlFWv<1xuRb3wjiJ#mUSX z+_2}QEkC?fPf%u|)oIJ$WI-V1k0w+OTV~qi>wxK)AWTusp1E5{LvY(A&C)ty0J;cOH1&N547cq zltQQiI4~Tmv$KW_xPYqy;4bENKT+)}L?h_?WSLc1>!F3%<6(H$$0E-v6xJ|gegZwb z&aq=ePY7HjHaYnQ5nYIhx!WY>o8{J%aTDaq5y*^4|A!|zKj1%`i7r^Ig-nK5Zf+^^ z1?)3FUEJm{)BED$2?ssCe9{spB=A8~fMCL!q)53-;0=b>nAzEBDr^G#Ts&%`&)nNZ5k6QvRmeBbYR zB>?;>vN~w9&2GTEgGkdqSNO{tULrHVFJ2I$R9u`P=&o9Z&{X$e@_S+IKY z*jH7uk{1Xf@3CoT5_)Y^oHicSPIpz$De##z~d zFTu)Xy8QP~WRorzV`spLuK^w=vM%#AExuTjHzBgpC>?%(P!ybZi3s6?<1>2QReebT zmTxce(5%ZO*WR+S-jz3kRDS)Npy>2N8}>4_l9I@MJ@9WgNUv7K=AP zL^OEmpeUAVEroUOD0l$3hH|MEQ*EvS5dg&Sf>9%Ye1bqoPSn@MRC2a#-!J59t|%Hn z7#y6OnO|OQZZ<6ckmk$ZqeXR5fQ1hFB_;BZ9{nNB%QglepUlRwXuO-2i<(- zf4p|WvHN)wxlr39fBc+d-`tYXcFK!89QfrU^T)@1|MGRapK@|C4Hiwfj$!?Af&JsE z(IDa=emGX_yalyDP883N(SvP5^WPaONWB8l3W>XbS^$nRb8^xpACfz`EB?U#-N)Mh zGVq>SxKu$)OG|POEF&pVW)b1Zvu6y)OWdW6Sfq1AX^ zWGBTrIxej%!$@Iu1q1uCv!f%{G>Rehb!pLFw^jGt?*1NnNJPWTtdr_(=>8p&=@T`y z9JUod4JAIb_)m;6vyO;jLjay)g^lsomYEC2o}TRgaq%Zou1hrprMrC!uHCHS(>aKk_p&g}j^KRB>4Ge)%9|MFOT$YmM8 z_h8WK0Z<@$Za|p5*cRBeiamHh@v@&6mefsc>%1D*(mmwa&M=PFk!} z4gQMWcO&@`e)-$aEr{0_(3WrLtyS&6FGa5*knqg5>K4>cJc5E@T-Zh9fmQ>g5P_R; z-&iACy>z%oSwI1j>;-NL9KwcjJW%RfB9k!6ZEdPA}_4APzG*x9#*b2kVa=gNl6K~V{?(vG2Z=|HLlY2 zuaYi=DLi8PbT`Dc%_rlTFx}|{r^^w-OePK~ZAI~M&^35?h@%yN7YZqy5bJH@C?`gW z2?UA`W6PeSodPE>UXNtLd3R8X8c(0{5e{(9>7HXGB% z6nl=Fs#_QtjcHX?BeYW=-6xkA5fS89L67_Mr9)3XnsGJXQ35s#%@pK~<&rJ_JETvB z(9cLiZb7Jrmz`flXJvYN94y(%SQWZzpqt?5BhcC+T|spTECzTOfFC=cU!*Y|Td;WY zk*Et`Eho#>8t*Jp`)b0g)J~Ga;!D6G_Q+!*6OIj>XT5@%+xi@(FFRLw1dhv<^cjB4 z)1$0XWs^)UxFT@IJ zKZM`9%N^dy#9|s-Kcn8-=1!BlWO)lpMuNI!b=X023|b0lr+E0;v8mv}AN+f`A)Xt& zXIGzh^S?T2Sx#O3W1N)#l{BdYO{`%&kMQ6yKcYv-dSZ^Uv9YO%^mCY~*I>P8d2ZIY zz&m}yUOE|*b|EOyARI9=h*-&ac>Za>@4U$$*neaJV%q!x{ZC;ACUd)p$Yy>io+J;pzMPy4L$t*xM`=xP4` z73Uo?6GOs@KM8;h-~nFhovbeX15~($XC* z$D0GW=LygWD=zZU1&z-(W1v7oF` zM?zD!L;FrfpmgQyhfEctGuoGcC=-fG*M5$6iCB6@au zQdIt$s8%EV4Kk#~@P>>F;UUu-7XpR){;M@sJ>KM4w7ZBcu^3?;i58$MEK>(rt>*o6 z8qS+oq)r%KX?y=(+tX7LWv`T++)J0RzW?brSD&-w?(!#&$ zIw)vx{QsZC0dha6(%{Xo6F@C92Z!!h&UjY+i0UqA8UQeYR{?acYMe-%}7lN3zXU_3xH1 z+sJb>t9rP**Tc*}-n#6<7O0^or>277R|c>?AV3*)XaUyMf*a__QK%~ztf+kbWz}6R z%--|Gs0iukO-$78m?oIf6Uha4Q_#q(GC^4@TI0BKRnznN`A?cYfBt;U+?4r+ur%~s z$HKIX&oZKn0T~;D;)CewFgPo7TdFnG<@A9G4=Eb^QAZ9Mxs)iSEt+oxkkN5$V)UFo z>}J2AIjFe)Oll+Ko=}bfjzMbB*C;o?V=mh3TPzjC+ z4Z(3WbM)xw*W*@JSh#fqnwAyUY= zIduU#vYakn6_x%|1uEY8Uj=C14tWlKX_`O_9+RGKRQfcL<;riM#x;`k$mRHKQ@_QR z$yk?*i!dpnU;#Y&^LH6Y5t>BdX_x{Ne|hAg8X*4&G|(gw%>xEQdUe&^9&v_Q22p&Q z=2k{j2Ox^o*;L5o91vPTpM)=m^qXnNj;PiY`I8gx({Nr;?!>gP!{XUdIBn6bMB$oA`_ITKcIpt4lI7DKX)D z(93H<#qgmlCe#qS?2+ZTn>w_G?oS)a?ldqk0F`rN-;?gEBc*;P%pL0(7#YcX2SSfj z3aVy6_R-=FwCZdZKe)YMx}n+9D>~R#TLOQZRMo&MH*Zb3XKwyy5HsOR%aJkM3I3go zgO>kYXnDSt+Ab1j@Dwm4X126|RT6(7UIk%}$hm>BW9JmO70ypG5sRZb7ujp}9Qp$f z%|(b10?7LyW{yx&)f4!`b}Y`;sjoj0 z#fB@tty`)U(lziLNVR#vXoV9JLuk9W88+gPkZmA{T^YW$DxXb;eqJKtMpz(xHCwB+ zWYKerY;ceQoQo$2@tdTwG`+=}_s1{1AM+Z5^o!({sJk2qmbdR7{a5HZB1nK!40Av} zXrAm+8g7-Xt*(BJQ7JdyHpyqaf>6}NS^uc*<*g@Ao;3FmM~RJxPpEW+8oWX@JM(aU z9D%X4&Ofz(RJ6l3+krK#z=%c_xCgJ2(%nr-gvI9NJ)kIu^nlW>eEs)#G9!d7X+B4_ zKYjfK!zmDVC*kM{bl!TQJjhc(;udO9$mxMPk$FK0+M6uKarbUp37=bFf(&0uFqCI?#JZ$n_lP`uJk6t4p?+ z=IyqlVv<(BoAmOSnCN}C>syNm2@RDWz$6ud-&{YwRX+2_6{YCQuk&M+Mv_j;<*7KKkAfv;SEfm5ez!W5k=9e#QBEwHG8c`EwGZJO5WZA)T ztyNxDmJG3yLR*us20|^Uo{76CYB#9*A(W&uEgB|{U=cah{g8b+&4hwy#@0J&~G6Zkq3rWwaq~9(t+(x0Rey0#kcYc#6+kV zUyTl#j59t^L%;`u-9ZysvQOE7^6=Ao@%N7&2~U5m+VyYt-SN_^J-B-e>lydZ}>iTS3+rl-Jfb4^R2*mQi?Pqz44(iys zl&^nj@E(Y_Fyt^lqI3Tk1Aq3;_`{8igW=9y*w(#8Jn{4=YmRl3%)Sv)D*&eik_I57 z0M=V5G7pN!{ZTOYP`?Jn2e{5!Aob99Lz`|d%Vla~!=PO)_FJIJB(Vr^5Qg#q8wne~ zgHC5YR3aegrYCfDud8HaWC)P-I710iYe=B=RrYVIgP7ZMCj&TQa_-S_7a}^_8vK=x zuag$_%cR&PUWS3yzi8QO0qqcJ3@+j7?R{~|kN4XT0wqJ=F> zB_*@Nr2yM%2-?9F)u_|v*VPV%DZh{q*%9Ikwv(6ThOpsGg&aphkWo$@d{4sOy*mW# zx$Bl0-^T~$B83-btO%ls6tno4_MB205!;H}lwXH)wga%%=Z~GF0}A75ycY8B+_N6Z zw_m|%8ksM|xVsi83FXv@m;Sy=!nAWzy#RNh{;&hskK$|jh$EertYT}C*go+~6+21D z#o_`Wq=>iB<^D*1)6n8i$}1Ebm|ENvg}-~wCY>cH)`1GBEC56Vb_`f{hV9GgQaq&) z4edM#G2_7BtT@D;I97O!MdzY6&u%?A`tuSwc9i6WA;Lj+Qvk_@FxySbsYYfAlWa44 zq;pzEMgq2zc}F0+U?9m9Ajo#&0sHt9WL#oDRA?0Ln^(bWt$eA|fVALPgaQHr1HDAB z2TGPeqPuqof8uTvI5&d4)!u%UBo+v6ZP&gX1*Yu3YgcjuA@C5RZfvs+U?x-6sPs1M z3-TUT6!P+=V&A=?T0gt9`?|C%lTf|8T;0@pvxB3hzELf?16W}+I394n@j+kj|0EcP z{)ow?Y=MEb7YDOmO^-TvTvQq6)DfddM^1bpBsDo64cR;{oeLBks(3zxmWFT!!{Mrb zdDnk;SCL72!2iY4N~5P!!!^K2fCj;&5LvEAFnx40c9J}x9n~`>riYceD;j1SOfqC2 zV^-=Uq+B5B_e1zhSsi{uh@W4L_1?Ts?4~5TQL|!BPzS#7-68U7IDu|LSz_)v)WsuP z@Oh&roGA%}>p5Ichy9Xju@A3?#J;<<4uuT5GN8bCo}e180!YO{ZTmbWj~@czQGuAj zc1w$*t3TPoL$(qw6g4}UCdNx3Gg3ZWK$7-mqlyV>aG=K*r23fkTn_MWxa+$W6_W+?bUE~{&#y6- z1zHZBSIvmIh^xW?Q=CQu3d{{2+?4qAgU5%lY5}2qy4)8NiaT-XNhrq1*n!`ZyKQ|~ zRsXy|mwY`cN(;OfJ{9Jzpm(IwTI?xVo%a`r41v2H|ji28Nu(pq_FX5wA_WD`m- z_=FBZya&J31a>h;Am3Xlt)TZQ1jg!+9FPJQp?K=HDgdwh=g5fh?#) zf}>9}Dw$qQ@qb*Qj|T*(1t#faz6Tpcza4 zYK5A>(pepo`|0Ib#W&aJ1jx^Nmu0m1DHPC0U`CCUnrH`oVZa6T@;=_eGCmcXD}F5p z)|Rh%duKMd2196H--io`?>`&F!@CEyLS$_@6}J1uHO@ zJKA_inEV=dd?nOD$qo~cddO1*`FZJ% zXkhX7E$kBqZ)SVC-1-(^hPH2!G0Hb$wvy>FqLc9TRk$!aVAvv=cvoWJt4ARjj9;%p z*~^2AgYH)5QlaRO+U-*&!fX)l)?;YMbM3bev5=Kdw)wK}?9%AJ5|dfOZ;e=!I!9ng z;vx@of$OkayZ_|LW)xW0^z$%ziY`9=-t(d)Z`HWMG;0BM1_27_g#RMjlNhrOAJ;7e zlOOe3y(#krG7os&gn`$DjziO-*!VDTJLNfh;Cp%0!@F4lkutVC&-NY**@^)jkCpS1~Fd{f5mH3ho1KZy$ zQ3>N#rdrE%q7-fOM2$wL#lg7)phfMQH?$~4&_n`GGZ*=|mrsmR=IkeZuXP|SbMIu@ z?%_CK#Pk?kg>a36z|BBi!iIAWZWd}KY*@h|BP)N_M^x8?FK7(7UO6L203xE5Tdw-I z1Dp>E98EQ~YO03RF)q>G#dL9Q-hYjf^oZ$1-|*v$B2h(2OW$@*Je5+N>R#|;H1_?e z;68Ub!omc?P8A*uIz4M|{yJv(Invf!r6^HCR}L zWvoq~0!T<42XDe@#CB`7p>ZtNrG(I~DTt6;5||d3%TeR;vPT?^pDy?pfg0dN-PS8 zMQv9%p{%Q+P4b)dseL(hUWtI_aXCiAILs>m1jzmsV$MncOmb2`exwFwedq1A6TfHw zmHu^iSi&R)9t@%@Z~`OVSkS!4co3Uo^x>M5<))d-aB9;8QW(WEJL-oy8^L0_|2-m0 z%NsD;Vc0I`KBq?~J$EQsO-bME%yQjb0xUHX)5l?Z51$e6IffEpRw zcPMmQOl+(aZ@hqZifjPW?77?uuk;C6+)Xy<{(7$;eCV%41&C+z63|J+GrDv6}lYG)cpVTldjj5m+_0t9w{e53|wf^a3d2%qNRpp3C43ilQGcFG- zrZ1*W*dy{IGuwVh`teN5JKLWn%LVYNl|(XMKtIBT1RpMP57x*-5}ba57~J1$B=E4X_L?u63c5=(-`t`R#_<7{q$NS)yDG+FrER zf`B@tD-_i}oBzjN4)>2*0?T}KbDHYRhUdunigQ*;FKrz1vcp^sQw*dT+IpmEkuqjnLLE%ess^UzK?4IN3sFL<5YGg-+00pcZmyKH*Ad9 z^|OGz(t~GHcpZi@7SUf8Us=0^7K0%?7`P+w5;AV;B1;@u-`+|noL#&Okqs#hfs{t8 z{Nl)p&^{@9qRRg4b5RpCeweUhUSl@ALzk|=Y?DVRW_)m>_#3v85S9q*_b@_u1yEb_ z)eS!==?4Di%10a@k?ft|RKCs7U_;F&b!s<3+TG+AbC?9-F{gv<-Z=4`>ROeh9cC(J*2?$8uITr_viytYTJ>Y^>&~q_LE-qTdh|(38e4dqes{G|9BM)<@MW>=WHduVNZ-Sc6>pC zq-S98y(Dt~@1$p8=CGBs$v-L`g)BL@@h^v=w&FJrSuWzLOgxr}=QkX;{~36D7e9}S zb9!d*Zl9*A(C}nnHcS0j`V-N8k`1q)J$pv>v10Td8hsMxNS#b(GCWGUdV2Pxd_<{C zwwaOLF^C1=!!u@n6Ba?SkEdu%BF-nGTf&IH|7-rrYlRo*12Ka6!Rhd};H&VZLj`zX z5Kfchcu_i1HlHU$rhzrye5IN8uUyrcmd1Mg1YRA?wo$T~iA`a3s=1+nxcDu2oDetc zD@fu5zd2dllcadkIm0=yqpHPDlAt`09&mDUViKd7PRstIz|IAB>n5-x&=$q;E{fSKU%37R;ap$2N328+)>10$w%p21d zJ(kv(aS@(a8{nYFDmkmliZ)cRx?OKqXL<82vj zfCm5P;18`*vZD9HclWnyt#;aV$9=atL>o~AB{o^01xRT1>84Ld&y&-FF^_w$TxVv^ zV2NTxdSYdOME~}OP_iQ~h5&cCgQ)_tt!?mK&FwSU!kdEZu6}B?P|u7x)EK#B{9m!H z%R@W%nwvX9?GXWyH~8g7locj zNebSs9^>c9cM88xVml{2m+W-_jv!H8-Q2z{{QdQ_7j?EUjn><7jKf_e)AUte+u*JW zy(iy;u-De#!SW;7zX!ZF#$+CtSA3|PAp?}89x|}{q zOM)wCORa?;d5h=oH6nS%#6V$U^akH#=`NwIs{~C<-E*T!0uveCxY>L218n)yG<{8aorEu87w~HFnosj%V14 z_Me`+Qf2$8wy(Dr{vF@thvfAXWkntx+2l9T|{DR)|ErL^M`t?ffS%x)*`JI(G~oJ=Is#DJCb8(;h;(muPt zj8IyxC|#)=lGJXvUN>Y{f1R0r*MU^jHwa?@P8wz|T(so5e6E){P=tp1iGYK8Ies{q zc|gbiK05I4NwO#k`3ID1ryoD4mj05&B8j&cqKWz23l|vBMK$k|nP+ZzDg+pIU8_j; zTR!5nOcfs&9{~{ zc95rX9l+5NWqCmijxk4){-EdELkn!|T z%eP%vEpbSj6_}SaEc}7y zMhed}dCven?~mk%t~hUGGavN zr!cZh#11_@dYFKMaumgG+r)R)?Na9|Hq=mPw+f%>z%sEKDDzK~jUwbZR(XPR~}G)>gmzCDP?uZY>SRGTb#pd zzVPEe#_t~NO|DpczrTFpttjbcuo}X%?@USa-7{xTUG47?==f<^gC*lXZ~wBdpq2$) zO+2o!aw7wg5K3{FVJ@&bze@^&w$-^9yHl|kZ<~rWt}>#%2lj9GxF2Qrb z36RNos5TTTT^3f>-99-{G$-@(lMC3jrS_nZ2aNTGuxuDU#9<;!N}&$Vv!yVyM7^g0=>(obw^P=qu8y<4f)26J1(4Axg5I@^VKxsuL2FI$MilfV+#Xo^ zhK+2XRau%%%0Au|&;CfMyZsUVv&^L>7jzClNgM%QQw1Ny|9~17LRWeY@y7zc>SHVZ z3^Q*@{js~bgY`y*PlZ<6Gi);=(=8Y|^oF@*B}(ZctQ|!qr)zDU8gQbvQ1e4Rue8)9 zz)RXuTv}KL@;CWfp+j*G#tQ|RXm#hl?S0WGs)btzuX5-1dk3+g@ujgGb1Yp3JSlKV zbD3@7laI~{Th-zjBq36)jpZtOb)`QrVNfcf`6F-`V%Lc_0CP*mWP&p%%lNY_2)<`i zYCM?rodD?#WPq zfBLd~RH0)@iN=<3!1Did0LGeDJi7$(6K|1%O^_3odfjt^&(OE_qmEp<^*(F6x!UIc`f0T^uL8|+X#b0#m9jaA>idy_>+QyWMU zvgzqCjG)oq)x!Iz5vkyRX3*H*S)d?RWWVIJx2Dql*X6wYKbI(en+L0ocn&5o*}^$Xe5)V!vj|~{;2wQMKLXxgKag?{78dy@jTWO zV(~ggm5L~LG3dxqhWGROGj7v6w6ySgR(?({rIjj}S@rdv!w+?T6!Wae%gLc#xOQLo z%ekf@ubm`}tDg?R-5Ifw%-Rt)jN=qnoLQvQ6A(Tf6!15Z_! zb^PAWE-%8KJCs~z98I!P0-5E(Hd}xHKTap=$DAK>;L+i10r=%_5$H&XYn#XdRb198 zzU^l#)gT{)yyPLECx?AMZXgU$u-*ArK=D)X>V#cN$0#yCuTS zklz)$hs1o6|yW5U3I#3hycdqnR(MNc+5s;MvHu-GYU zD0lbI$*AS+z7e|>uOb%e)T{CECVt*v=L_tq}K;9uFC21YiRU3s~xz%5wBEQq?^;7f@14KJfY#0OQ-Nt9k#<;{$=-~B!NS;(7+6lK8-K#^_&Q4jml@v1gY4`=bT_En z$(Yp2&-}nvGQx!rdJ~e5sq?>j{blb)-tKph)#AR4ng$C`oPRIFU8#t2++nU|K-Ra~Eqd6%G?0h97UH+~tq3abha8T5s)j*rKgjEU38*f`Qz{=9XqlUGlP zy=9b-$)3G?j{?K6vJ%08`42o^p<#mUTg$pFi&FQ?6XH_h=sj!`uA|_m$S+L7lotaa z;Q|Clv^8+pGct-`>3;vbd35{XR;6X#L5fK?Z|Xy=!yS`2C4CT2h3pDg|DpjiOi5(_ zn0oFHkpUb+aobFAO~^XwPvq9TK!C_)hzij{Brsju7CxorPa0mB;L;1_t6<&so%ZEq zM;O=D3B~A5s#;o-6}?&2Y)VF)={SJv7AHuZ#1CrN;m0 z7vfu0R0Bo#+AzIchSvfjo;y?=RGFe1Ghli9%y$qF*MGaDBpu_w&)2C0@R{aL9IB+ouvjD;?x`$8peS``KX=hu3yyNx( zRS_ZabU3_UA=u#uCxd|*%@MieEkzg7x?&tZ}$k_5dyuTWeTw*l^v+iLYg~*NrXcf6UT!uXojk@~OC098##;L5aEcV>vmhs0@kMq{)Ns0i0dH0krW5h_)vY+(zPlmU^wy z&vP5oe~qWVpXpKrISNGc4CW1ikY@SRjS9IoGxSvpOO%~g0OQ!HwmnjsCl8)2u&an? z(&?YHj_iqdOa7mW@eYJL_G2<7Y#*7Y2;>VGzYuRV`g^<*;4lZhD@7>I`~c+em?6%HGyCZ@uj5@o$QCMLfh`Fr_IG)U>U6Hgr>6&n9@m|#M-oqC+7(H z6(S-=+I<$(9n0985x=zmnMP@KUiJ7k{%3zCH65hUWF;73wf!w{#|srnTDOFlf`FOA{9@V zRLKVn$ra}w&zIPKS=2u-7oi=z*}{>QpOdq*BD8)$VoZk9-xDmk?ML1U1qYV0!!uq| z$Z#dIb92wassoBiJDN4V=fB+*8*2NMs>NZAf*u?4DD+f7lHq*?jV}K6v+>!E_wO6Q zEp8jJo9G=H9Avqif=BmQxo zR-N?G#DH{*WcPtGRd(r=NBa%aYU)XFqTIgs2iG%?u<68>~ff$bs6$xG>)Le<&-A9Y7E%+T>*g#omiPCxKv zIjDCa-TNbrmY?Y2dW6qIe9g6)0;gWKhi23+xsB_%_5G%BY2|nBWaZ_VwTCU|LXSo= za0ra(=60+84=CIaRMSLH<=o85%-n@v9}pATJsgH;Tu?9*H{a8i*PfkUT;Hjbkk_57 zg0@ET>5I#dV$6HK+?~Qren!y19$y#XTQ!vyNBVLX#FZBb0E^aDdU2ROHOBbxKO9d zUw;{NP>lAX35x3i!}l4)ZGt*zlY!=EtjT$VAgy*k;&gS zx&sZnc?LB$E$t1U3AIaK#V|eL_-l+9aJFLi`Y1>TZ@f*#4kpR00h>zhBV_hd(;bI* z;~wHf#)F0>jC4>Pcp~O!wBL1hBG#ZD6%Fj3BXUBCTA$HSPZ~a?L791Ycz}WU>hM82 zjghuCwlv}9O}=oie5YP5uIUvr4k4b|;~Udg^9u~_!;-?-xBgXi z066A=EYffS@rkP+Yxh!}36ei{Db4FJdsNAQ#VIJeAACtk%rZkbw9#)luc%MyLf_+9>JD_DhlQ_X(ds0_wIJUPpYO2<{+R zY6YB0IEnOhM0`qME+V)%1}ltiI8ovhWMr8I)2{lb!mxZp6g0pJNr!;04ZUj+1kdRA zK%(Fn+znP1OmGwA7fo;8+*19?c`@`lsTHNgWL@~(6;HY87MDJjeeE(S-R63wQ)K4R z&qHx3o6kiuHmL*&Y^8F=4kYG$*+H_IfC#CfH7ipsBVaZ0FT9FLC%ab3v$EiCYKVp} zhyw(HgN}^`n1%HLw64-X_;ASyi-IPHFi=2T+xhjwb#ZJ2YRp}JoMP#4CH0lpgoNbY zev4yF69dY;Uh5-$1x*YgirGnp8$K1YUq-jqoo1x%lUb#qtfIV)+R(A$J`lH+`krkq zsTyRd4cuG8lT!dzAPQvZe|h_k_0}bU-i5_*f~&+_@YA#20%b@v)CMbCsn6w|B$x^M z^B>$yTdFR!e~SnQ_=&Sqrs5v6S3!TQq`3I3S!u2M0)5)eLz&;it|!cbDLUXF{=t<@ z^AO28efy^gJ~REZ^8`XiO@>6psQm69L1u!BZ^yh%#!+wIH`AKi9K2S&RFyfjx4DFg zCuHfR(tt+PHlq4Qlc?w+b;R!E$%AzIW3lcC@4PlWMxY=wVBAwZg29l}(Xq3zjftF$ z6#G&rbe&f>wZLk&%sFXoFWqnCh-v~6jaepWx~be0z#dan)aWkOJtdmf3J3ibjqoDB z_$E(trn{A)NQk0s>YO^euCniX-HFB7sp^~WGI<*`P*QP2&y;sL=9yD$nZmzbvv`;d zC!DIAX$wES9)_su28L_dQ>tey)MFUqXZ5b!anF2~Lw-5-dFUnNwO|wb(INNuhy3e}z;o3rxLzxXjB&H7Q#Nak@ z=A(518#1Twi)BlCM)Z2#y!t{1QU-CmHtls2(<=_+ z2%F(6GzwS+7H)3R#lAmCE+DA8T;D58GA|Va*axdx3!>2!B#hEI>k}P=txJA2|HYzD z+hS&g@W2er45}kIE~5}LJll{&F&Z-L7i$ZPR*|-(fb=+}NrH_R(TG0e7VILhrilkTs}P-Rt>9YFAqpvJRQkIny~c z=Iz6D!)C$NeWv22UK78J;nnAEYraf4W0HU1%6WUUm)Fu@dJI3g^&8gDMENX(-P5}L zFfAu^^JP!>g5DU=fbfEb&qhg?sjI82u;`N-&*SX6Qw&k{LsX}rl$?7Zs%W09PmW6S zfMEqYa~CEk=`C`WJTs4KNOsg!N<%AU4W?*Q)9AImT7FL>zP<(okBuRHd{X3rI#-qW z*jSiJgiiZ*>hX*(5ofMeDveZCSlazm=kQLyfnZ^%W%Ke9$`05R%)f9zOU;EPr6E|n z2+BMyVn2;LQDE4z1j>U97_R`~R#U+W&c6SB;)4T^q=G^W4xqTyRE3xdrLq?@F_t{z zf$@Ro?U&xWDi(9t%W3HBBY{QJ-D!;-d_RNMxfVBrE=@#;5>c^h`WYZ>+4g1~0LEyW(g#=yG>MkOQU{{inw;O#)sjaA~d zBfPu7Y+$$tFKxkQ;gdjiAg=T!C`sgAv{#&BjCNBp_!KeDfVk@u^qssm;!%;LaVbCd zyrEMo$WhcQ?S%RF=ImK~Il)R9RaSaub+Wb~~Tuj27T|3bzU z^;8lDijmF&t0GrhKI>Upl4*fu6ptY|e5dR42g_#HxTUmOmfCSuyxBLx%^&ftFq$?K z{_3<(=?Bn%UqjMZoPMo8ch?pQy>O<)B_@)ENetrvAyBVGjE)}{ax1SisW9LX(W+S` zpkTUjOlG?_n=-~+CqVyBzM)O0uD?R7$c>m(P*Gf{v2eE*P|WR$-fSUrarAD_l+`1N zYT>6o?;VN`Zh3cpM2Ry?#RHTcdoU_n-bp;HC$Ro&IG?g}iuT%qh7;bYTfoY~TGGue zpGBjZJoWy4giqb>D1IVs(ks2R>bRO|>c)EwCuERDjrI-@bu3f}_@%*|%I*YQjM!L7 z{u^cP!OYRiv-+GxP=d`EEWrh@SQGc! zN4#q0)hQ^MI9YffGMd60hENSA;2BReoxuG7OaoLPI?#P$rnmRR0QVEj2+DL3a1s}P z;>%MtBQPeq+TZ?*`|fLf<8siP0W$@K0GN^+sM@BO*zJR8;BoKJRlOT)nf~v?Hp#Dy zeDK%j;bB5dVV0bvR|5`5_kgY%ooh00XuW;4T3>vB-gr0^zzs2X6uBQ7|FEQ9vAI~8 zAtFB~rnl~Dd4RyoqvZWmMsM{Cyzwrv$=n$ZoyjuQYkGqcv+;v|J%b(@@9ER0_3chQ zf8e=2igU7^r@v>B#u2t*6f8^P-0hi_6BXB(lNEF?npvISz!qur;%5(?U)hT*xZ5$5 z&YAS$jYk5DxgbRL(3hIMGsmU08_# literal 0 HcmV?d00001 From 6e3a9af21b8cef1820f04642936ad716569603f1 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:43:39 -0500 Subject: [PATCH 04/14] always-enabled feature spec --- docs/features/always-enabled-machine.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/features/always-enabled-machine.md diff --git a/docs/features/always-enabled-machine.md b/docs/features/always-enabled-machine.md new file mode 100644 index 0000000..a50e361 --- /dev/null +++ b/docs/features/always-enabled-machine.md @@ -0,0 +1,10 @@ +# Always-Enabled Machine + +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. From 5e47272553a7c4118855f995d08fa3a1fa21cc58 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:46:41 -0500 Subject: [PATCH 05/14] CLAUDE.md feature instructions --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 720af64..408af24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,3 +177,7 @@ Optional for MAC server: - 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. From 564d7c35ea1169d551bb2319b568bc479df3ce87 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 08:53:31 -0500 Subject: [PATCH 06/14] always-enabled: add detailed implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive implementation plan for always-enabled machine feature, breaking down the work into 4 milestones with specific tasks and acceptance criteria. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/features/always-enabled-machine.md | 155 ++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/docs/features/always-enabled-machine.md b/docs/features/always-enabled-machine.md index a50e361..7440baa 100644 --- a/docs/features/always-enabled-machine.md +++ b/docs/features/always-enabled-machine.md @@ -1,5 +1,7 @@ # 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: @@ -8,3 +10,156 @@ Please be sure to add unit tests for this new functionality for AT LEAST the fol 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:** Planning complete, awaiting approval to begin implementation + +**Current Milestone:** None (planning phase) + +**Completed Milestones:** None From 5f88faa7c035f4ff9df3ed80e4d0767e807f8b8f Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:01:36 -0500 Subject: [PATCH 07/14] always-enabled - 1.3: Configuration and Model Updates complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1 complete: Added always_enabled configuration option to Machine model. Changes: - Updated CONFIG_SCHEMA to include optional always_enabled boolean property - Added always_enabled attribute to Machine class with default value False - Updated Machine.as_dict property to include always_enabled field - Updated existing tests to expect always_enabled in as_dict output - Updated feature document with Milestone 1 completion status All existing tests passing (141/141). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/features/always-enabled-machine.md | 11 ++++++++--- src/dm_mac/models/machine.py | 11 +++++++++++ tests/models/test_machine.py | 3 +++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/features/always-enabled-machine.md b/docs/features/always-enabled-machine.md index 7440baa..dd32230 100644 --- a/docs/features/always-enabled-machine.md +++ b/docs/features/always-enabled-machine.md @@ -158,8 +158,13 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: ## Implementation Status -**Status:** Planning complete, awaiting approval to begin implementation +**Status:** In progress - implementing Milestone 2 -**Current Milestone:** None (planning phase) +**Current Milestone:** Milestone 2: State Logic Updates -**Completed Milestones:** None +**Completed Milestones:** +- **Milestone 1: Configuration and Model Updates** (Completed) + - Added `always_enabled` boolean to CONFIG_SCHEMA + - Added `always_enabled` attribute to Machine class + - Updated `as_dict` property to include `always_enabled` + - All existing tests passing diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index e17ff6a..1db25cb 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, } diff --git a/tests/models/test_machine.py b/tests/models/test_machine.py index ee946f7..da32405 100644 --- a/tests/models/test_machine.py +++ b/tests/models/test_machine.py @@ -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, } From c7ca3875179c254ed37db43602c0c7c51edc5c72 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:04:16 -0500 Subject: [PATCH 08/14] always-enabled - 2.3: State Logic Updates complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 2 complete: Implemented always-enabled machine logic in MachineState. Changes: - Added ALWAYS_ON_DISPLAY_TEXT constant to MachineState class - Updated MachineState.update() to handle always-enabled machines: * After oops handling, check if machine.always_enabled is True * If always-enabled and not oopsed/locked: set relay on, display "Always On", green LED * Skip RFID processing for always-enabled machines - Updated unoops() method to restore always-on state when un-oopsing always-enabled machines - Updated feature document with Milestone 2 completion status All existing tests passing (141/141). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/features/always-enabled-machine.md | 11 +++++++-- src/dm_mac/models/machine.py | 30 ++++++++++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/features/always-enabled-machine.md b/docs/features/always-enabled-machine.md index dd32230..fb9ef18 100644 --- a/docs/features/always-enabled-machine.md +++ b/docs/features/always-enabled-machine.md @@ -158,9 +158,9 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: ## Implementation Status -**Status:** In progress - implementing Milestone 2 +**Status:** In progress - implementing Milestone 3 -**Current Milestone:** Milestone 2: State Logic Updates +**Current Milestone:** Milestone 3: Unit Tests **Completed Milestones:** - **Milestone 1: Configuration and Model Updates** (Completed) @@ -168,3 +168,10 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: - Added `always_enabled` attribute to Machine class - Updated `as_dict` property to include `always_enabled` - All existing tests passing + +- **Milestone 2: State Logic Updates** (Completed) + - 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 + - All existing tests passing diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index 1db25cb..bb5cc0a 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -198,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): @@ -366,11 +368,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, @@ -409,7 +418,18 @@ async def update( if oops: await self._handle_oops(users) self.last_update = time() - if rfid_value != self.rfid_value: + # Handle always-enabled machines - ignore RFID, always on unless Oopsed/Locked + 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 + # Don't process RFID changes for always-enabled machines + elif rfid_value != self.rfid_value: if rfid_value is None: await self._handle_rfid_remove() else: From 6a3bef0650fee8f7ff3abaac978d7856cfab159a Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:13:55 -0500 Subject: [PATCH 09/14] always-enabled - 3.x: Unit tests implementation (in progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3 in progress: Added comprehensive unit tests for always-enabled machines. Changes: - Created test_machine_always_enabled.py with 5 comprehensive tests: * test_always_enabled_basic: Tests relay on and "Always On" display * test_always_enabled_oopsed: Tests oops and un-oops behavior * test_always_enabled_ignores_rfid_insert: Tests RFID insert is ignored * test_always_enabled_ignores_rfid_remove: Tests RFID remove is ignored * test_always_enabled_first_contact: Tests immediate enable on first contact - Added always-on-machine to fixtures/machines.json - Fixed test expectations for machine count (5 -> 6) - Updated Slack handler tests to include always-on-machine in status output - Partially updated Prometheus test fixtures (LED metrics complete) Status: 143/146 tests passing Remaining: Complete Prometheus test fixture updates for all metric types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/features/always-enabled-machine.md | 10 +- tests/fixtures/machines.json | 4 + tests/models/test_machine.py | 4 +- tests/test_slack_handler.py | 2 + tests/views/test_machine_always_enabled.py | 249 +++++++++++++++++++++ tests/views/test_prometheus.py | 8 + 6 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 tests/views/test_machine_always_enabled.py diff --git a/docs/features/always-enabled-machine.md b/docs/features/always-enabled-machine.md index fb9ef18..eeb64ad 100644 --- a/docs/features/always-enabled-machine.md +++ b/docs/features/always-enabled-machine.md @@ -158,9 +158,9 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: ## Implementation Status -**Status:** In progress - implementing Milestone 3 +**Status:** In progress - finalizing Milestone 3 test fixtures -**Current Milestone:** Milestone 3: Unit Tests +**Current Milestone:** Milestone 3: Unit Tests (nearly complete) **Completed Milestones:** - **Milestone 1: Configuration and Model Updates** (Completed) @@ -175,3 +175,9 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: - Always-enabled machines skip RFID processing and show "Always On" - Updated `unoops()` to restore always-on state for always-enabled machines - All existing tests passing + +- **Milestone 3: Unit Tests** (In Progress - 5/5 tests written, fixing test fixtures) + - Created 5 comprehensive tests for always-enabled functionality + - Added always-on-machine to test fixtures + - Fixed test expectations for machine count changes + - Remaining: Fix Prometheus test fixtures to include always-on-machine metrics diff --git a/tests/fixtures/machines.json b/tests/fixtures/machines.json index 060af8d..8740731 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": ["This list is ignored"], + "always_enabled": true } } diff --git a/tests/models/test_machine.py b/tests/models/test_machine.py index da32405..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) 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..249cb98 --- /dev/null +++ b/tests/views/test_machine_always_enabled.py @@ -0,0 +1,249 @@ +"""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, + } + + # Un-oops the machine by posting oops=false + # Machine should return to always-on state + 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 + # After un-oops, always-enabled machine should return 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 ignores RFID card insertion.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + + # Insert an RFID card (authorized user) + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "1234567890", # This is user 1 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, + } + + @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 ignores RFID card removal.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + mname: str = "always-on-machine" + + # Insert an RFID card first + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "1234567890", + "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 + + # 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, + } + + @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, + } diff --git a/tests/views/test_prometheus.py b/tests/views/test_prometheus.py index ac620e1..f583936 100644 --- a/tests/views/test_prometheus.py +++ b/tests/views/test_prometheus.py @@ -223,6 +223,10 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="blue",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="brightness",machine_name="restrictive-lathe"} 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 machine_status_led{led_attribute="red",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="green",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="blue",machine_name="esp32test"} 0.0 @@ -379,6 +383,10 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="blue",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="brightness",machine_name="restrictive-lathe"} 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 machine_status_led{led_attribute="red",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="green",machine_name="esp32test"} 0.0 machine_status_led{led_attribute="blue",machine_name="esp32test"} 0.0 From f62649a074c529072ffad9a2ed4aca6566691e7f Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:21:09 -0500 Subject: [PATCH 10/14] always-enabled - 3.4: Unit tests and test fixtures complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3 complete: All unit tests written and all test fixtures updated. Changes: - Fixed test_always_enabled_oopsed to use DELETE /api/machine/oops endpoint for un-oopsing - Added always-on-machine metrics to all Prometheus test fixtures - Fixed Prometheus LED metric ordering (always-on-machine after esp32test) All 146 tests passing! Test coverage: - Basic always-on behavior ✓ - Oops/un-oops behavior ✓ - RFID insert/remove ignored ✓ - Immediate enable on first contact ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/views/test_machine_always_enabled.py | 33 ++++++++++++++-- tests/views/test_prometheus.py | 44 ++++++++++++++++++---- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/tests/views/test_machine_always_enabled.py b/tests/views/test_machine_always_enabled.py index 249cb98..7565a9b 100644 --- a/tests/views/test_machine_always_enabled.py +++ b/tests/views/test_machine_always_enabled.py @@ -107,8 +107,7 @@ async def test_always_enabled_oopsed(self, tmp_path: Path) -> None: "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, } - # Un-oops the machine by posting oops=false - # Machine should return to always-on state + # Release oops button (oops=false) - machine stays oopsed response = await client.post( "/api/machine/update", json={ @@ -123,7 +122,35 @@ async def test_always_enabled_oopsed(self, tmp_path: Path) -> None: ) assert response.status_code == 200 json_response = await response.json - # After un-oops, always-enabled machine should return to always-on state + # 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, diff --git a/tests/views/test_prometheus.py b/tests/views/test_prometheus.py index f583936..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 @@ -223,14 +237,14 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="blue",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="brightness",machine_name="restrictive-lathe"} 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 machine_status_led{led_attribute="red",machine_name="esp32test"} 0.0 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 @@ -274,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 @@ -281,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 @@ -288,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 @@ -295,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 @@ -302,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 @@ -309,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 @@ -316,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 @@ -323,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 @@ -330,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 @@ -337,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 @@ -344,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 @@ -351,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 @@ -358,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 @@ -365,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,14 +411,14 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None: machine_status_led{led_attribute="green",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="blue",machine_name="restrictive-lathe"} 0.0 machine_status_led{led_attribute="brightness",machine_name="restrictive-lathe"} 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 machine_status_led{led_attribute="red",machine_name="esp32test"} 0.0 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 From b6ff1f3b6dc2e77a32f9b562ef0c1240928c6fcd Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:41:40 -0500 Subject: [PATCH 11/14] =?UTF-8?q?always-enabled=20-=204.4:=20Feature=20com?= =?UTF-8?q?plete!=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 4 complete: All acceptance criteria met. Changes: - Updated CLAUDE.md with always_enabled configuration documentation - Verified 100% test coverage - Verified all nox sessions passing (tests, mypy, pre-commit, safety) - Moved feature file to docs/features/completed/ Feature Summary: Added always_enabled boolean option to machine configuration. When enabled: - Machine is always enabled without RFID authentication - Displays "Always On" on LCD - Relay is always on (unless Oopsed or Locked) - RFID card insertions/removals are ignored - Immediately enabled on first server contact Final Status: ✅ All 146 tests passing ✅ 100% code coverage ✅ All quality checks passing ✅ Full documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 3 ++ .../{ => completed}/always-enabled-machine.md | 30 ++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) rename docs/features/{ => completed}/always-enabled-machine.md (91%) diff --git a/CLAUDE.md b/CLAUDE.md index 408af24..2269e22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,9 @@ poetry run neongetter **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_-]` diff --git a/docs/features/always-enabled-machine.md b/docs/features/completed/always-enabled-machine.md similarity index 91% rename from docs/features/always-enabled-machine.md rename to docs/features/completed/always-enabled-machine.md index eeb64ad..55e0025 100644 --- a/docs/features/always-enabled-machine.md +++ b/docs/features/completed/always-enabled-machine.md @@ -158,26 +158,36 @@ Add comprehensive test coverage in `tests/views/test_machine.py`: ## Implementation Status -**Status:** In progress - finalizing Milestone 3 test fixtures +**Status:** ✅ COMPLETE -**Current Milestone:** Milestone 3: Unit Tests (nearly complete) +**All Milestones Completed:** -**Completed Milestones:** -- **Milestone 1: Configuration and Model Updates** (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` - - All existing tests passing -- **Milestone 2: State Logic Updates** (Completed) +- **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 - - All existing tests passing -- **Milestone 3: Unit Tests** (In Progress - 5/5 tests written, fixing test fixtures) +- **Milestone 3: Unit Tests** ✅ - Created 5 comprehensive tests for always-enabled functionality - Added always-on-machine to test fixtures - - Fixed test expectations for machine count changes - - Remaining: Fix Prometheus test fixtures to include always-on-machine metrics + - 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 From cebb59c994e577c44cd064fdc82f3826acdebb49 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 09:45:07 -0500 Subject: [PATCH 12/14] minor version bump to 0.3.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7a3813a..6e995a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine_access_control" -version = "0.2.3" +version = "0.3.0" description = "Decatur Makers Machine Access Control package" authors = ["Jason Antman "] license = "MIT" From 91d75257fa7e84aa4df632904bf06b77409249b9 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 16:29:12 -0500 Subject: [PATCH 13/14] fix: Address Copilot review comments on always_enabled feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved all 4 Copilot review comments: 1. Updated unlock() to restore always-on state for always-enabled machines - Checks machine.always_enabled and restores appropriate state - Ensures consistent behavior after maintenance lockout 2. Updated _handle_reboot() to restore always-on state for always-enabled machines - Checks machine.always_enabled and sets appropriate state directly - No longer relies on subsequent always-enabled logic in update() 3. Fixed misleading comment in test fixture - Changed "This list is ignored" to "Not used for authentication" - Clarifies that authorizations_or is a schema requirement 4. Added last_update timestamp to always-enabled path - Ensures consistent timestamp tracking across all state change paths - Prevents stale last_update for always-enabled machines Testing: - Added 2 new integration tests: * test_always_enabled_unlock - verifies state restoration after unlock * test_always_enabled_reboot - verifies state restoration after reboot - All 148 tests passing - 100% code coverage maintained 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/dm_mac/models/machine.py | 35 ++++++--- tests/fixtures/machines.json | 2 +- tests/views/test_machine_always_enabled.py | 89 ++++++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index bb5cc0a..4423a55 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -303,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: @@ -342,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.""" @@ -428,6 +442,7 @@ async def update( 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() # Don't process RFID changes for always-enabled machines elif rfid_value != self.rfid_value: if rfid_value is None: diff --git a/tests/fixtures/machines.json b/tests/fixtures/machines.json index 8740731..1149cae 100644 --- a/tests/fixtures/machines.json +++ b/tests/fixtures/machines.json @@ -19,7 +19,7 @@ "unauthorized_warn_only": true }, "always-on-machine": { - "authorizations_or": ["This list is ignored"], + "authorizations_or": ["Not used for authentication"], "always_enabled": true } } diff --git a/tests/views/test_machine_always_enabled.py b/tests/views/test_machine_always_enabled.py index 7565a9b..a3611d7 100644 --- a/tests/views/test_machine_always_enabled.py +++ b/tests/views/test_machine_always_enabled.py @@ -274,3 +274,92 @@ async def test_always_enabled_first_contact(self, tmp_path: Path) -> None: "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, + } From 0e47b771af247e523172765a453884ccf86e4675 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 16 Nov 2025 17:18:38 -0500 Subject: [PATCH 14/14] Track RFID values on always-enabled machines for auditing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented RFID value tracking for always-enabled machines to support auditing while maintaining the always-on state. RFID insertions and removals are now logged with user information and session duration. Changes: - Added _handle_rfid_tracking_always_enabled() method to track RFID changes without affecting machine state - Updated always-enabled logic in update() to call tracking method when RFID value changes - Updated test_always_enabled_ignores_rfid_insert() to verify RFID values, current_user, and rfid_present_since are tracked - Updated test_always_enabled_ignores_rfid_remove() to verify RFID removal clears tracked values - Fixed test RFID values to use "8114346998" (Ashley Williams) instead of invalid "1234567890" All 148 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/dm_mac/models/machine.py | 52 +++++++++++++++++++++- tests/views/test_machine_always_enabled.py | 22 +++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index 4423a55..dfa3430 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -432,7 +432,7 @@ async def update( if oops: await self._handle_oops(users) self.last_update = time() - # Handle always-enabled machines - ignore RFID, always on unless Oopsed/Locked + # Handle always-enabled machines - track RFID but maintain always-on state if ( self.machine.always_enabled and not self.is_oopsed @@ -443,7 +443,9 @@ async def update( self.status_led_rgb = (0.0, 1.0, 0.0) self.status_led_brightness = self.STATUS_LED_BRIGHTNESS self.last_update = time() - # Don't process RFID changes for always-enabled machines + # 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() @@ -599,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/views/test_machine_always_enabled.py b/tests/views/test_machine_always_enabled.py index a3611d7..2ab015f 100644 --- a/tests/views/test_machine_always_enabled.py +++ b/tests/views/test_machine_always_enabled.py @@ -161,12 +161,13 @@ async def test_always_enabled_oopsed(self, tmp_path: Path) -> None: @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 ignores RFID card insertion.""" + """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( @@ -174,7 +175,7 @@ async def test_always_enabled_ignores_rfid_insert(self, tmp_path: Path) -> None: json={ "machine_name": mname, "oops": False, - "rfid_value": "1234567890", # This is user 1 from users.json + "rfid_value": "8114346998", # Ashley Williams from users.json "uptime": 12.3, "wifi_signal_db": -54, "wifi_signal_percent": 92, @@ -191,15 +192,21 @@ async def test_always_enabled_ignores_rfid_insert(self, tmp_path: Path) -> None: "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 ignores RFID card removal.""" + """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( @@ -207,7 +214,7 @@ async def test_always_enabled_ignores_rfid_remove(self, tmp_path: Path) -> None: json={ "machine_name": mname, "oops": False, - "rfid_value": "1234567890", + "rfid_value": "8114346998", # Ashley Williams "uptime": 12.3, "wifi_signal_db": -54, "wifi_signal_percent": 92, @@ -217,6 +224,9 @@ async def test_always_enabled_ignores_rfid_remove(self, tmp_path: Path) -> None: 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( @@ -241,6 +251,10 @@ async def test_always_enabled_ignores_rfid_remove(self, tmp_path: Path) -> None: "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: