From f026a334fd2388a276ef88f7d6e01af649fca803 Mon Sep 17 00:00:00 2001 From: Oliver Wahlen Date: Sat, 13 Jun 2026 23:52:24 +0200 Subject: [PATCH] ported python to go --- .env.example | 18 ++ .github/workflows/gatecontrol.yml | 44 ++-- MIGRATION.md | 370 ++++++++++++++++++++++++++++++ README.md | 247 ++++++++++++-------- app/api/auth.py | 31 --- app/api/config.py | 53 ----- app/api/gate_hardware.py | 120 ---------- app/api/gate_router.py | 28 --- app/api/gate_service.py | 145 ------------ app/api/health_router.py | 13 -- app/api/health_service.py | 47 ---- app/api/index.py | 10 - app/api/logger.py | 8 - app/api/models.py | 22 -- app/main.py | 20 -- app/tests/test_auth.py | 33 --- app/tests/test_config.py | 58 ----- app/tests/test_gate_router.py | 81 ------- app/tests/test_gate_service.py | 119 ---------- app/tests/test_health_router.py | 44 ---- app/tests/test_health_service.py | 74 ------ app/tests/test_index.py | 23 -- app/tests/test_main.py | 37 --- app/tests/testing_utils.py | 7 - cmd/gatecontrol/main.go | 114 +++++++++ documentation/HttpWebHooks.json | 18 -- documentation/architecture.puml | 10 +- documentation/gatecontrol.service | 23 +- go.mod | 15 ++ go.sum | 14 ++ internal/app/service.go | 129 +++++++++++ internal/app/service_test.go | 270 ++++++++++++++++++++++ internal/config/config.go | 118 ++++++++++ internal/config/config_test.go | 95 ++++++++ internal/dido/periph.go | 58 +++++ internal/dido/spi_relay.go | 160 +++++++++++++ internal/dido/spi_relay_test.go | 172 ++++++++++++++ internal/domain/ports.go | 23 ++ internal/domain/types.go | 35 +++ internal/domain/types_test.go | 34 +++ internal/mocks/dido.go | 97 ++++++++ internal/mocks/mqtt.go | 104 +++++++++ internal/mqtt/client.go | 209 +++++++++++++++++ requirements.txt | 7 - 44 files changed, 2222 insertions(+), 1135 deletions(-) create mode 100644 .env.example create mode 100644 MIGRATION.md delete mode 100644 app/api/auth.py delete mode 100644 app/api/config.py delete mode 100644 app/api/gate_hardware.py delete mode 100644 app/api/gate_router.py delete mode 100644 app/api/gate_service.py delete mode 100644 app/api/health_router.py delete mode 100644 app/api/health_service.py delete mode 100644 app/api/index.py delete mode 100644 app/api/logger.py delete mode 100644 app/api/models.py delete mode 100644 app/main.py delete mode 100644 app/tests/test_auth.py delete mode 100644 app/tests/test_config.py delete mode 100644 app/tests/test_gate_router.py delete mode 100644 app/tests/test_gate_service.py delete mode 100644 app/tests/test_health_router.py delete mode 100644 app/tests/test_health_service.py delete mode 100644 app/tests/test_index.py delete mode 100644 app/tests/test_main.py delete mode 100644 app/tests/testing_utils.py create mode 100644 cmd/gatecontrol/main.go delete mode 100644 documentation/HttpWebHooks.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/service.go create mode 100644 internal/app/service_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/dido/periph.go create mode 100644 internal/dido/spi_relay.go create mode 100644 internal/dido/spi_relay_test.go create mode 100644 internal/domain/ports.go create mode 100644 internal/domain/types.go create mode 100644 internal/domain/types_test.go create mode 100644 internal/mocks/dido.go create mode 100644 internal/mocks/mqtt.go create mode 100644 internal/mqtt/client.go delete mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27e44cd --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +export MQTT_BROKER=localhost +export MQTT_PORT=1883 +export MQTT_CLIENT_ID=gatecontrol +export MQTT_USERNAME=faacuser +export MQTT_PASSWORD= +export MQTT_BASE_TOPIC=faac/gate +export LOG_LEVEL=info + +export SPI_BUS=0 +export SPI_CHIP_SELECT=0 +export SPI_HARDWARE_ADDR=0 +export DIDO_RELAY_PIN=0 +export SPI_SPEED_HZ=100000 +export PULSE_LENGTH_MS=500 + +# Local development only. +export USE_MOCK_DIDO=true +export USE_MOCK_MQTT=true diff --git a/.github/workflows/gatecontrol.yml b/.github/workflows/gatecontrol.yml index 64a41ef..a0b5a06 100644 --- a/.github/workflows/gatecontrol.yml +++ b/.github/workflows/gatecontrol.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: gatecontrol on: @@ -9,31 +6,24 @@ on: jobs: build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with unittest - run: | - python -m unittest discover -s app/tests + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache: true + + - name: Verify module files + run: go mod tidy -diff + + - name: Test + run: go test ./... + - name: Build ARMv6 binary + run: | + GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 \ + go build -o bin/gatecontrol-armv6 ./cmd/gatecontrol diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..f022331 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,370 @@ +# Migration Plan: Python/REST DIDO to Go/MQTT DIDO + +This document outlines the work needed to replace the current Python implementation in +`gatecontrol` with the Go/MQTT implementation from `gatecontrol2`, while keeping the +working DIDO board relay control instead of using the USB FAAC protocol. + +## Current State + +### `gatecontrol` + +`gatecontrol` currently contains a Python FastAPI service: + +- REST endpoints receive open/close requests. +- `GateService` translates requests into a relay pulse. +- `DidoSpiRelayHardware` talks to the DIDO board over SPI. +- State is reported back to Homebridge through HTTP webhooks. +- The latest DIDO backend no longer depends on `piface`; it directly writes to an + MCP23S17-style SPI GPIO expander using `spidev`. +- The current DIDO mode only controls `Relay0`; it does not read gate status inputs. + Because of that, the service keeps an assumed state in memory after a command. + +Important existing Python behavior to preserve: + +- Pulse `IN 1` by setting the relay active for `0.5s`, then inactive, then wait another + `0.5s`. +- Avoid pulsing if the assumed state already equals the requested target. +- Default DIDO settings: + - `SPI_BUS=0` + - `SPI_CHIP_SELECT=0` + - `SPI_HARDWARE_ADDR=0` + - `DIDO_RELAY_PIN=0` +- MCP23S17 writes: + - `IOCON` register `0x0A` + - `IODIRA` register `0x00` + - `GPIOA` register `0x12` + - control byte `0x40 | ((hardware_addr << 1) & 0x0E) | rw_bit` + - write frames are `[control, register, data]` + +### `gatecontrol2` + +`gatecontrol2` contains the desired Go/MQTT shape: + +- Go application entrypoint in `cmd/gatecontrol2/main.go`. +- MQTT command subscriber and state publisher in `internal/mqtt`. +- Domain types for commands and states in `internal/domain`. +- Testable architecture using ports/interfaces and mocks. +- Cross-compilation documentation for Raspberry Pi Zero/Zero W ARMv6. + +However, its gate control path is USB-specific: + +- `internal/usb` sends reverse-engineered FAAC serial frames. +- `internal/app.Service` periodically polls the USB transport for position frames. +- State is derived from USB-reported wing positions. + +This USB protocol must not be used for this migration. + +## Target Architecture + +The migrated `gatecontrol` should be a Go service with these responsibilities: + +- Subscribe to MQTT commands: + - `/command` payload `open` + - `/command` payload `close` +- Publish MQTT state: + - `/state` + - `/target_state` + - `/availability` +- Control the existing DIDO board over SPI. +- Keep the old assumed-state behavior until input feedback is implemented. +- Build as a static Go binary for Raspberry Pi Zero/Zero W. + +Recommended package layout: + +```text +cmd/gatecontrol/main.go +internal/app/service.go +internal/config/config.go +internal/domain/types.go +internal/domain/ports.go +internal/dido/spi_relay.go +internal/mqtt/client.go +internal/mocks/dido.go +internal/mocks/mqtt.go +``` + +The important change from `gatecontrol2` is the hardware boundary. Replace the +USB-oriented `GateTransport` abstraction with a DIDO-oriented interface, for example: + +```go +type GateHardware interface { + Pulse(ctx context.Context) error + Close() error +} +``` + +If input feedback is added later, extend this deliberately instead of carrying over the +USB poll/read model: + +```go +type GateFeedback interface { + ReadOpen(ctx context.Context) (bool, error) + ReadClosed(ctx context.Context) (bool, error) +} +``` + +## Migration Steps + +1. Remove Python runtime files from `gatecontrol`. + + Delete or replace: + + - `app/` + - `requirements.txt` + - Python-specific test setup + - FastAPI/REST documentation + - Basic Auth and webhook configuration references + +2. Copy the reusable Go structure from `gatecontrol2`. + + Bring over and rename: + + - `go.mod` and `go.sum` + - `cmd/gatecontrol2/main.go` to `cmd/gatecontrol/main.go` + - `internal/config` + - `internal/mqtt` + - `internal/domain/types.go` + - `internal/domain/ports.go`, after changing the hardware interfaces + - `internal/app`, after replacing USB poll logic with DIDO pulse logic + - `internal/mocks` + - Go tests, adapted to the new assumed-state behavior + +3. Remove the USB protocol implementation. + + Do not migrate these files into the final service: + + - `internal/usb/serial_transport.go` + - `internal/usb/serial_transport_unsupported.go` + - USB command frame constants + - USB poll frame constants + - position response parser + - `DetermineState` logic based on wing positions + + The FAAC USB code was valuable for `gatecontrol2`, but it is the wrong hardware path + for this device. + +4. Implement Go DIDO/SPI relay control. + + Create a Linux DIDO adapter that mirrors the current Python `DidoSpiRelayHardware`. + + Required behavior: + + - Open `/dev/spidev.`. + - Configure SPI mode, bits per word, and speed. + - Initialize the MCP23S17: + - write `IOCON` + - write `IODIRA=0x00` + - write `GPIOA=0x00` + - Maintain an in-memory `gpioaState`. + - On relay activation, set bit `1 << DIDO_RELAY_PIN` and write `GPIOA`. + - On relay deactivation, clear bit `1 << DIDO_RELAY_PIN` and write `GPIOA`. + - Close the file descriptor on shutdown. + + Implementation options: + + - Use `golang.org/x/sys/unix` directly, consistent with `gatecontrol2`'s low-level + serial implementation. + - Or use a maintained SPI package if it works on ARMv6 without CGO and keeps + deployment simple. + + If using `unix` directly, the implementation must issue Linux `SPI_IOC_*` ioctls. + This is the main low-level risk area because Go does not expose a high-level spidev + API in the standard library. + +5. Rework `internal/app.Service` for DIDO. + + The Go service should no longer poll hardware every `500ms`. + + Suggested initial DIDO behavior: + + - On startup: + - publish `availability=online` after DIDO initialization succeeds + - publish initial `state=STOPPED` + - On `open` command: + - if assumed state is already `OPEN`, do nothing + - otherwise publish `target_state=open` + - pulse relay + - publish `state=OPEN` + - store assumed state `OPEN` + - On `close` command: + - if assumed state is already `CLOSED`, do nothing + - otherwise publish `target_state=close` + - pulse relay + - publish `state=CLOSED` + - store assumed state `CLOSED` + - On pulse failure: + - publish `availability=offline` + - keep the previous assumed state + - On clean shutdown: + - turn relay off as best effort + - publish `availability=offline` + + This intentionally matches the current Python DIDO limitations. It does not claim + real gate position unless DIDO input feedback is added later. + +6. Keep MQTT from `gatecontrol2`, with project naming updates. + + Suggested defaults: + + - `MQTT_BROKER=localhost` + - `MQTT_PORT=1883` + - `MQTT_CLIENT_ID=gatecontrol` + - `MQTT_BASE_TOPIC=faac/gate` + - `LOG_LEVEL=info` + + Keep retained MQTT messages for state, target state, and availability. + +7. Replace configuration. + + Keep these from `gatecontrol2`: + + - `MQTT_BROKER` + - `MQTT_PORT` + - `MQTT_CLIENT_ID` + - `MQTT_USERNAME` + - `MQTT_PASSWORD` + - `MQTT_BASE_TOPIC` + - `LOG_LEVEL` + - mock flags for local testing, if still useful + + Add or preserve these from `gatecontrol`: + + - `SPI_BUS` + - `SPI_CHIP_SELECT` + - `SPI_HARDWARE_ADDR` + - `DIDO_RELAY_PIN` + - `SPI_SPEED_HZ` with default `100000` + - optionally `PULSE_LENGTH_MS` with default `500` + + Remove these: + + - `HOST` + - `PORT` + - `BASIC_AUTH_USERNAME` + - `BASIC_AUTH_PASSWORD` + - `WEBHOOK_URL` + - `ACCESSORY_ID` + - `USB_DEVICE` + - `USB_BAUD` + - `USE_MOCK_USB` + +8. Rewrite tests around DIDO behavior. + + Test cases should cover: + + - Config defaults and environment parsing for MQTT and SPI. + - Command normalization accepts only `open` and `close`. + - `open` from `STOPPED` pulses once and publishes target/state. + - `close` from `OPEN` pulses once and publishes target/state. + - Duplicate `open` while assumed `OPEN` does not pulse. + - Duplicate `close` while assumed `CLOSED` does not pulse. + - Pulse failure publishes offline and does not update assumed state. + - Shutdown calls hardware close and publishes offline. + - SPI adapter builds the same MCP23S17 control bytes and register writes as Python. + + The hardware tests should not require a real Pi. Put register/control-byte logic in + small functions that can be unit-tested without `/dev/spidev*`. + +9. Update deployment artifacts. + + Update `documentation/gatecontrol.service`: + + - remove Python-specific `PYTHONPATH` + - remove REST/webhook/basic-auth variables + - add MQTT variables + - keep SPI variables + - change `ExecStart` to the Go binary path, for example: + + ```ini + ExecStart=/home/pi/gatecontrol/gatecontrol + ``` + + Update README instructions: + + - install/build Go binary instead of Python requirements + - document MQTT topics instead of REST endpoints + - document ARMv6 build command: + + ```bash + GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build -o bin/gatecontrol-armv6 ./cmd/gatecontrol + ``` + +10. Verify on target hardware. + + Local verification: + + - `go test ./...` + - run with mock DIDO and mock MQTT if those flags are kept + + Raspberry Pi verification: + + - confirm `/dev/spidev0.0` exists + - confirm SPI is enabled in `raspi-config` or `/boot/config.txt` + - confirm service user can access the SPI device + - run with `LOG_LEVEL=debug` + - send MQTT command `open` + - verify exactly one relay pulse on the DIDO board + - send duplicate `open` + - verify no second pulse while assumed state is `OPEN` + - send `close` + - verify exactly one relay pulse + - reboot and verify systemd startup + +## Open Questions + +- Should the initial assumed state be `STOPPED`, as today, or should it be configurable + as `OPEN`/`CLOSED` for restart behavior? + Decision: Assume `CLOSED` as the initial assumed state. +- Should the service persist the last assumed state to disk so a restart does not forget + whether the gate was last commanded open or closed? + Decision: No, do not persist. +- Should MQTT payloads stay as `OPEN`/`CLOSED` for current state and `open`/`close` for + target state, matching `gatecontrol2`, or should they use HomeKit numeric door states? + Decision: Use exactly the same payloads as `gatecontrol2`. +- Which Homebridge/HomeKit MQTT plugin will consume the new topics? The old + `homebridge-http-webhooks` integration will no longer apply directly. + Decision: We will use `homebridge-mqttthing` with a simple MQTT door accessory configuration. This should however not be your concern as the goal is only to operate MQTT! +- Is only `Relay0` needed permanently, or should the Go DIDO package support all output + pins for future expansion? + Decision: If it is possible without much extra complexity, support all output pins. Otherwise, hardcode `Relay0` and add a TODO for future expansion. +- Does the DIDO board require active-high relay behavior in all deployments, or should + relay polarity be configurable? + Decision: I do not know this, try to research it. If you cannot find out limit your work to what is currently possible. +- Are FAAC `OUT 1` and `OUT 2` still physically connected to DIDO inputs anywhere? If so, + the migration can later add real input feedback instead of assumed state. + Decision: This is a future improvement. For now feedback pins are not connected. +- Which Raspberry Pi model is the production target now: original Zero/Zero W ARMv6 or + Zero 2W ARM64? The build documentation should make one the default. + Decision: It is the older model Zero W with ARMv6. + +## Risks + +- The Go SPI implementation is the riskiest new code. The current Python implementation + relies on `spidev`; a Go replacement must correctly configure Linux spidev ioctls and + byte transfers on Raspberry Pi. +- Without input feedback, MQTT state remains assumed state. It can become wrong if the + gate is operated by a remote, keypad, wall switch, safety stop, obstacle detection, or + power interruption. +- The USB service's polling/state code cannot be reused as-is. Keeping it would create a + misleading architecture that expects unavailable FAAC position frames. +- MQTT changes the integration contract. Homebridge configuration must move away from + HTTP webhooks or use an MQTT-capable bridge/plugin. +- GPIO expander initialization mistakes can leave relay outputs in the wrong state. The + Go adapter should explicitly write relay off during startup and shutdown. +- SPI permissions differ by Raspberry Pi OS version. The systemd service may need group + membership such as `spi` or a udev rule. +- Cross-compilation must remain compatible with ARMv6 if the original Pi Zero/Zero W is + still used. Avoid CGO-only dependencies unless deployment builds happen directly on the + Pi. + +## Recommended First Implementation Slice + +The safest first slice is: + +1. Import Go config, MQTT, domain types, mocks, and tests from `gatecontrol2`. +2. Replace `GateTransport` with a minimal `GateHardware.Pulse` interface. +3. Implement service logic with mock DIDO only. +4. Get `go test ./...` passing locally. +5. Implement the Linux DIDO SPI adapter. +6. Test a single relay pulse on the Pi before connecting it to the FAAC input. +7. Update README and systemd service after the binary behavior is verified. diff --git a/README.md b/README.md index c37f1de..a18150c 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,186 @@ # Gatecontrol -[![Build Status](https://github.com/owahlen/gatecontrol/actions/workflows/gatecontrol.yml/badge.svg?branch=main)](https://github.com/owahlen/gatecontrol/actions/workflows/gatecontrol.yml?query=branch%3Amain++) - -REST service for a Raspberry Pi Zero operating an FAAC-E124 gate control unit. - -## Controlling a gate with Apple Hardware -The goal of this project is to control an entry gate with an Apple device (e.g. iPhone or Apple Watch). -More specifically the gate is represented as a [HomeKit accessory](https://www.apple.com/ios/home) -and controlled with [Siri](https://www.apple.com/siri) or the -[Home app](https://apps.apple.com/de/app/home/id1110145103). - -## Homebridge configuration -HomeKit requires a [home hub](https://support.apple.com/en-gb/HT207057) (e.g. an Apple TV) -to be installed on the local network. -The home hub connects to Apple servers and allows the Home app to control HomeKit accessories even -from outside the local network. - -Currently, few accessories natively support HomeKit. -The [Homebridge](https://homebridge.io) project allows integration of smart home devices -that do not natively support HomeKit. The software can be installed on a -[Raspberry Pi](https://www.raspberrypi.org/products/raspberry-pi-4-model-b) -that is placed into the local network. -Homebridge registers with the home hub and exposes accessories to HomeKit through installable plugins. -It is configured through a web interface under `http://ip_address_of_homebridge:8581`. - -For the gate in this project to be exposed as HomeKit accessory the -[homebridge-http-webhooks](https://github.com/benzman81/homebridge-http-webhooks) plugin -needs to be installed. The -[HttpWebHooks.json](documentation/HttpWebHooks.json) file -gives an example on how to configure the plugin. -Note that [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) is used to secure the communication -between the gate hardware and Homebridge. The Basic Auth header string can be generated by -selecting a _user/password_ combination and by replacing `my_user` and `my_password` in the following command. -The string `xxxxxxxxxxxxxxxxxxxxxxxxxx==` in the plugin configuration needs to be replaced -by the outcome of this command. +Go service for a Raspberry Pi Zero W operating a FAAC-E124 gate control unit +through a DIDO relay board and MQTT. + +## Overview + +The service subscribes to MQTT commands, pulses the DIDO relay connected to the +FAAC `IN 1` input, and publishes the assumed gate state back to MQTT. + +There is no USB FAAC protocol support in this project. The FAAC control is +operated through the existing DIDO board only. + +## MQTT Topics + +The MQTT base topic defaults to `faac/gate`. + +### `faac/gate/command` + +Inbound command topic. Supported payloads: + +```text +open +close +``` + +### `faac/gate/state` + +Retained state topic. Published payloads: + +```text +OPEN +CLOSED ``` -echo -n my_user:my_password | base64 + +The service starts with assumed state `CLOSED`. Because no feedback pins are +connected yet, this state is based on the last successful command, not on real +gate position. + +### `faac/gate/target_state` + +Retained target topic. Published payloads: + +```text +open +close ``` -Also note that `ip_address_of_pi_zero` needs to be replaced with the ip address -of the Raspberry Pi Zero WH mentioned below. -The components of the system are shown in the following diagram. +### `faac/gate/availability` -![component diagram](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.github.com/owahlen/gatecontrol/main/documentation/architecture.puml) +Retained availability topic. Published payloads: + +```text +online +offline +``` ## Gate Hardware -The hardware that is used to control the entrance gate is a FAAC-E124 Control Unit. + +The hardware that is used to control the entrance gate is a FAAC-E124 Control +Unit. ![FAAC-E124 Control Unit](documentation/e124.png) -Since the E124 does not provide any way to interface with the Homebridge it is extended with the following -hardware components: -* [Raspberry Pi Zero WH](https://www.amazon.de/Raspberry-Pi-Zero-WH/dp/B07BHMRTTY) -* [DIDO module](https://www.amazon.de/Modul-Digital-Output-Module-Raspberry/dp/B07KZQCS38) -* [4 Channel Optocoupler](https://www.amazon.de/gp/product/B07Y8LFJBT) -* [USB Powerconverter](https://www.amazon.de/gp/product/B07XT8V97Y) +The FAAC unit is extended with: + +* Raspberry Pi Zero W +* DIDO module with MCP23S17-compatible SPI GPIO expander +* 4 Channel Optocoupler +* USB Powerconverter ### Circuit Diagram -These components need to be connected according to the following circuit diagram: + +These components need to be connected according to the following circuit +diagram: + ![Circuit Diagram](documentation/circuit-diagram.png) ## FAAC-E124 Control Unit Configuration -Please refer to the [FAAC-E124 manual](http://www.faac.co.uk/productfiles/245_Manual_rad0ADBE.pdf) -for details on the control unit. Chapter 5 of the document explains how to program the device. + +Please refer to the FAAC-E124 manual for details on the control unit. Chapter 5 +of the document explains how to program the device. + The following values need to be configured: -``` + +```text LO = E or EP o1 = 05 o2 = 06 ``` -Setting `LO` to either `E` or `EP` configures the input `IN 1` to operate the gate semi-automatically: -A first impulse on the input will open the gate. A second one will close it. -Configuring `o1` to `05` activates output `OUT 1` in _OPEN_ or _PAUSE_ state of the gate. -Setting `o2` to `06` activates output `OUT 2` in _CLOSED_ state. - -## Raspberry Pi Zero WH Configuration -Please see the [Getting started](https://www.raspberrypi.org/products/raspberry-pi-zero-w) -instructions for setting up the Raspberry Pi Zero WH. -It is mandatory to configure the Pi Zero for remote access in the local network via -[SSH](https://www.raspberrypi.org/documentation/computers/remote-access.html). - -### Install the _gatecontrol_ web service -As a next step the _gatecontrol_ web service must be installed on the Pi Zero. -This services operate the gate hardware and interfaces with the homebridge-http-webhooks plugin. -Copy the whole `gatecontrol` directory into the folder `/home/pi` on the device. +Setting `LO` to either `E` or `EP` configures input `IN 1` to operate the gate +semi-automatically: a first impulse opens the gate, a second impulse closes it. + +`o1` and `o2` are documented for future input feedback. The current Go service +does not read those outputs yet. + +## Configuration + +Environment variables: + +* `MQTT_BROKER` (default: `localhost`): MQTT broker hostname or IP address +* `MQTT_PORT` (default: `1883`): MQTT broker port +* `MQTT_CLIENT_ID` (default: `gatecontrol`): MQTT client id +* `MQTT_USERNAME`: optional MQTT username +* `MQTT_PASSWORD`: optional MQTT password +* `MQTT_BASE_TOPIC` (default: `faac/gate`): MQTT base topic +* `LOG_LEVEL` (default: `info`): `debug`, `info`, `warn`, or `error` +* `SPI_BUS` (default: `0`): SPI bus used by the DIDO board +* `SPI_CHIP_SELECT` (default: `0`): SPI chip select used by the DIDO board +* `SPI_HARDWARE_ADDR` (default: `0`): MCP23S17 hardware address +* `DIDO_RELAY_PIN` (default: `0`): DIDO output pin used for the relay +* `SPI_SPEED_HZ` (default: `100000`): SPI transfer speed +* `PULSE_LENGTH_MS` (default: `500`): relay active time and follow-up delay +* `USE_MOCK_DIDO` (default: `false`): use in-memory DIDO mock +* `USE_MOCK_MQTT` (default: `false`): use in-memory MQTT mock + +## Build + +Local build: + +```bash +go build ./cmd/gatecontrol ``` -scp -r gatecontrol pi@ip_address_of_pi_zero: + +Run tests: + +```bash +go test ./... ``` -Make sure python3 and all required modules are installed: +Cross-compile for the original Raspberry Pi Zero W: + +```bash +GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build -o bin/gatecontrol-armv6 ./cmd/gatecontrol ``` -sudo apt update -sudo apt install python3 -sudo pip3 install -r /home/pi/gatecontrol/requirements.txt + +## Raspberry Pi Setup + +Enable SPI on the Pi: + +```bash +sudo raspi-config ``` -### Start _gatecontrol_ service at boot time -Adjust the file [gatecontrol.service](documentation/gatecontrol.service) -to your needs and copy it into the folder `/lib/systemd/system` on the Pi Zero. -Configure it as a systemd service: +Then check that a device such as `/dev/spidev0.0` exists: + +```bash +ls -l /dev/spidev*.* +``` + +Copy the compiled binary to the Pi, for example: + +```bash +scp bin/gatecontrol-armv6 pi@ip_address_of_pi_zero:/home/pi/gatecontrol/gatecontrol ``` + +Adjust [gatecontrol.service](documentation/gatecontrol.service), copy it to +`/lib/systemd/system`, and enable the service: + +```bash sudo systemctl daemon-reload sudo systemctl enable gatecontrol.service sudo systemctl start gatecontrol.service ``` -### Environment Variables -The following environment variables can be set to configure the service. -If a variable is not defined the value in brackets is used as default. -The basic authentication is activated if both `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are provided. - -* `HOST` (0.0.0.0): The host interface where this service is running -* `PORT` (8000): The port the service is listening -* `BASIC_AUTH_USERNAME`: The username that must be passed to the service in a basic auth header -* `BASIC_AUTH_PASSWORD`: The password that must be passed to the service in a basic auth header -* `WEBHOOK_URL` (http://localhost:51828): The URL of the homebridge running the _Homebridge Webhooks_ plugin -* `ACCESSORY_ID` (gatecontrol): The accessory ID as configured as gate in the _Homebridge Webhooks_ plugin -* `SPI_BUS` (0): SPI bus used by the DIDO board -* `SPI_CHIP_SELECT` (0): SPI chip select used by the DIDO board -* `SPI_HARDWARE_ADDR` (0): MCP23S17 hardware address configured on the DIDO board -* `DIDO_RELAY_PIN` (0): DIDO output pin used for `Relay0` - -### Current DIDO Mode -The current implementation only drives `Relay0` over SPI. It does not use `libgpiod` and does not read gate status inputs yet, so the service keeps an assumed state in memory after each successful command. - -### _gatecontrol_ API Documentation -The service utilizes the [FastAPI](https://fastapi.tiangolo.com/) framework. -It generates an OpenAPI under the URL `http://HOST:PORT/docs` e.g. http://ip_address_of_pi_zero:8000/docs. +## Local Mock Run + +The mock DIDO backend mimics the relay pulse sequence without touching +`/dev/spidev*`. + +```bash +USE_MOCK_DIDO=true USE_MOCK_MQTT=true go run ./cmd/gatecontrol +``` + +## Current Limitations + +The service does not read gate feedback inputs yet. It assumes the gate is +initially `CLOSED` and updates the state after successful commands. The assumed +state can be wrong if the gate is operated by another remote, keypad, wall +switch, safety stop, obstacle detection, or power interruption. ## Related Projects -* [FAAC-E145-Gate-Connect](https://github.com/jens62/FAAC-E145-Gate-Connect) - A similar project specifically for the **FAAC E145** board. + +* [FAAC-E145-Gate-Connect](https://github.com/jens62/FAAC-E145-Gate-Connect) - + similar FAAC work for a different board and protocol diff --git a/app/api/auth.py b/app/api/auth.py deleted file mode 100644 index a58ead6..0000000 --- a/app/api/auth.py +++ /dev/null @@ -1,31 +0,0 @@ -import secrets - -from fastapi import HTTPException -from fastapi.security import HTTPBasicCredentials, HTTPBasic -from starlette import status -from starlette.requests import Request - -from app.api.config import config - - -class ConfigurableHTTPBasic: - def __init__(self): - self.http_basic = HTTPBasic() - - async def __call__(self, request: Request): - if config.is_basic_auth_active(): - return await self.http_basic(request) - else: - return None - - -def authorize_request(credentials: HTTPBasicCredentials): - if credentials is not None and config.is_basic_auth_active(): - correct_username = secrets.compare_digest(credentials.username, config.basic_auth_username) - correct_password = secrets.compare_digest(credentials.password, config.basic_auth_password) - if not (correct_username and correct_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Basic"}, - ) diff --git a/app/api/config.py b/app/api/config.py deleted file mode 100644 index 3fcb916..0000000 --- a/app/api/config.py +++ /dev/null @@ -1,53 +0,0 @@ -import os - -from app.api.logger import logger - -LOG_LEVEL = 'LOG_LEVEL' -HOST = 'HOST' -PORT = 'PORT' -BASIC_AUTH_USERNAME = 'BASIC_AUTH_USERNAME' -BASIC_AUTH_PASSWORD = 'BASIC_AUTH_PASSWORD' -WEBHOOK_URL = 'WEBHOOK_URL' -ACCESSORY_ID = 'ACCESSORY_ID' -SPI_BUS = 'SPI_BUS' -SPI_CHIP_SELECT = 'SPI_CHIP_SELECT' -SPI_HARDWARE_ADDR = 'SPI_HARDWARE_ADDR' -DIDO_RELAY_PIN = 'DIDO_RELAY_PIN' - -DEAULT_LOG_LEVEL = "INFO" -DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = "8000" -DEFAULT_WEBHOOK_URL = "http://localhost:51828" -DEFAULT_ACCESSORY_ID = "gatecontrol" -DEFAULT_SPI_BUS = 0 -DEFAULT_SPI_CHIP_SELECT = 0 -DEFAULT_SPI_HARDWARE_ADDR = 0 -DEFAULT_DIDO_RELAY_PIN = 0 - - -class Config: - def __init__(self): - self.reload() - - def reload(self): - log_level = os.getenv(LOG_LEVEL, DEAULT_LOG_LEVEL) - logger.setLevel(log_level) - self.host = os.getenv(HOST, DEFAULT_HOST) - self.port = os.getenv(PORT, DEFAULT_PORT) - self.basic_auth_username = os.getenv(BASIC_AUTH_USERNAME) - self.basic_auth_password = os.getenv(BASIC_AUTH_PASSWORD) - self.webhook_url = os.getenv(WEBHOOK_URL, DEFAULT_WEBHOOK_URL) - self.accessory_id = os.getenv(ACCESSORY_ID, DEFAULT_ACCESSORY_ID) - self.spi_bus = int(os.getenv(SPI_BUS, DEFAULT_SPI_BUS)) - self.spi_chip_select = int(os.getenv(SPI_CHIP_SELECT, DEFAULT_SPI_CHIP_SELECT)) - self.spi_hardware_addr = int(os.getenv(SPI_HARDWARE_ADDR, DEFAULT_SPI_HARDWARE_ADDR)) - self.dido_relay_pin = int(os.getenv(DIDO_RELAY_PIN, DEFAULT_DIDO_RELAY_PIN)) - if not self.is_basic_auth_active(): - logger.warning( - "BasicAuth deactivated. Set BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD environment variables.") - - def is_basic_auth_active(self) -> bool: - return self.basic_auth_username is not None and self.basic_auth_password is not None - - -config = Config() diff --git a/app/api/gate_hardware.py b/app/api/gate_hardware.py deleted file mode 100644 index f2eb32f..0000000 --- a/app/api/gate_hardware.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import typing -from dataclasses import dataclass - -from app.api.config import config - -if typing.TYPE_CHECKING: - from collections.abc import Callable - - -@dataclass(frozen=True) -class LineEvent: - line_offset: int - is_active: bool - - -class GateHardware: - supports_inputs = False - debug_line_offset: int | None = None - - def read_open_input(self) -> bool: - raise NotImplementedError - - def read_closed_input(self) -> bool: - raise NotImplementedError - - def set_relay(self, is_active: bool) -> None: - raise NotImplementedError - - def start(self, callback: "Callable[[LineEvent], None]") -> None: - return - - def close(self) -> None: - return - - -class DidoSpiRelayHardware(GateHardware): - IOCON = 0x0A - IODIRA = 0x00 - GPIOA = 0x12 - - BANK_OFF = 0x00 - INT_MIRROR_OFF = 0x00 - SEQOP_OFF = 0x20 - DISSLW_OFF = 0x00 - HAEN_ON = 0x08 - ODR_OFF = 0x00 - INTPOL_LOW = 0x00 - - def __init__( - self, - bus: int, - chip_select: int, - hardware_addr: int, - relay_pin: int, - speed_hz: int = 100_000, - ): - try: - import spidev - except ModuleNotFoundError as exc: - raise RuntimeError( - "spidev is not installed. Install the 'spidev' package to use the DIDO relay backend." - ) from exc - - self.hardware_addr = hardware_addr - self.relay_pin = relay_pin - self._gpioa_state = 0 - self._spi = spidev.SpiDev() - self._spi.open(bus, chip_select) - self._spi.max_speed_hz = speed_hz - self._init_chip() - - @classmethod - def from_config(cls) -> "DidoSpiRelayHardware": - return cls( - bus=config.spi_bus, - chip_select=config.spi_chip_select, - hardware_addr=config.spi_hardware_addr, - relay_pin=config.dido_relay_pin, - ) - - def read_open_input(self) -> bool: - raise RuntimeError("Input feedback is not configured on this hardware backend.") - - def read_closed_input(self) -> bool: - raise RuntimeError("Input feedback is not configured on this hardware backend.") - - def set_relay(self, is_active: bool) -> None: - bit_mask = 1 << self.relay_pin - if is_active: - self._gpioa_state |= bit_mask - else: - self._gpioa_state &= ~bit_mask - self._write_register(self._gpioa_state, self.GPIOA) - - def close(self) -> None: - self._spi.close() - - def _init_chip(self) -> None: - ioconfig = ( - self.BANK_OFF - | self.INT_MIRROR_OFF - | self.SEQOP_OFF - | self.DISSLW_OFF - | self.HAEN_ON - | self.ODR_OFF - | self.INTPOL_LOW - ) - self._write_register(ioconfig, self.IOCON) - self._write_register(0x00, self.IODIRA) - self._write_register(0x00, self.GPIOA) - - def _write_register(self, data: int, address: int) -> None: - self._spi.xfer2([self._control_byte(write=True), address, data]) - - def _control_byte(self, write: bool) -> int: - rw_bit = 0 if write else 1 - board_addr_pattern = (self.hardware_addr << 1) & 0x0E - return 0x40 | board_addr_pattern | rw_bit diff --git a/app/api/gate_router.py b/app/api/gate_router.py deleted file mode 100644 index 534cdb8..0000000 --- a/app/api/gate_router.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import Depends, APIRouter, BackgroundTasks -from fastapi.security import HTTPBasicCredentials -from starlette.responses import Response -from starlette.status import HTTP_200_OK - -from app.api import auth -from app.api.auth import ConfigurableHTTPBasic -from app.api.gate_service import GateService -from app.api.models import GateOut, GateIn - -gate_router = APIRouter() -security = ConfigurableHTTPBasic() -gate_service = GateService() - - -@gate_router.get('', response_model=GateOut) -async def get_gate_state(): - state = await gate_service.get_current_gate_state() - return {"currentdoorstate": state} - - -@gate_router.post('') -async def move_gate(payload: GateIn, - background_tasks: BackgroundTasks, - credentials: HTTPBasicCredentials = Depends(security)): - auth.authorize_request(credentials) - background_tasks.add_task(gate_service.request_gate_movement, payload.action) - return Response(status_code=HTTP_200_OK) diff --git a/app/api/gate_service.py b/app/api/gate_service.py deleted file mode 100644 index 39f7617..0000000 --- a/app/api/gate_service.py +++ /dev/null @@ -1,145 +0,0 @@ -import typing -from asyncio import sleep -from enum import Enum - -import httpx - -from app.api.config import config -from app.api.gate_hardware import DidoSpiRelayHardware, GateHardware -from app.api.logger import logger - -PULSE_LENGTH = 0.5 - - -class TargetState(int, Enum): - OPEN = 0 - CLOSED = 1 - - -class CurrentState(int, Enum): - OPEN = 0 - CLOSED = 1 - OPENING = 2 - CLOSING = 3 - STOPPED = 4 - - -class GateService: - - def __init__(self, hardware: typing.Optional[GateHardware] = None): - self.hardware = hardware or DidoSpiRelayHardware.from_config() - if self.hardware.supports_inputs: - self.last_stable_state = self._get_current_gate_state() - self.hardware.start(self._handle_input_event) - else: - self.last_stable_state = CurrentState.STOPPED - - async def request_gate_movement(self, target_state: TargetState) -> None: - if not self.hardware.supports_inputs: - state = self._get_current_gate_state() - if target_state == TargetState.OPEN and state == CurrentState.OPEN: - return - if target_state == TargetState.CLOSED and state == CurrentState.CLOSED: - return - await self._pulse_in1() - self.last_stable_state = ( - CurrentState.OPEN if target_state == TargetState.OPEN else CurrentState.CLOSED - ) - self._send_state(target_state, self.last_stable_state) - return - - state = self._get_current_gate_state() - if target_state == TargetState.OPEN: - if state == CurrentState.CLOSING: - # stop the closing - await self._pulse_in1() - self._send_state(None, CurrentState.STOPPED) - # open the gate - await self._pulse_in1() - self._send_state(None, CurrentState.OPENING) - self.last_stable_state = CurrentState.OPENING - elif state == CurrentState.CLOSED or state == CurrentState.STOPPED: - # only open the gate if it is currently closed or stopped - await self._pulse_in1() - else: - if state == CurrentState.OPENING: - # stop the opening - await self._pulse_in1() - self._send_state(None, CurrentState.STOPPED) - # close the gate - await self._pulse_in1() - self._send_state(None, CurrentState.CLOSING) - self.last_stable_state = CurrentState.CLOSING - elif state == CurrentState.OPEN or state == CurrentState.STOPPED: - # only close the gate if it is currently open or stopped - await self._pulse_in1() - - async def get_current_gate_state(self) -> CurrentState: - return self._get_current_gate_state() - - def _get_current_gate_state(self) -> CurrentState: - if not self.hardware.supports_inputs: - return self.last_stable_state - # FAAC-E124 Configuration - # OUT 1: OPEN or PAUSE (o1 = 05) - # OUT 2: CLOSED (o2 = 06) - out1_open = self.hardware.read_open_input() - out2_closed = self.hardware.read_closed_input() - logger.debug("OUT1: %d, OUT2: %d", out1_open, out2_closed) - if out1_open and not out2_closed: - self.last_stable_state = CurrentState.OPEN - return CurrentState.OPEN - elif not out1_open and out2_closed: - self.last_stable_state = CurrentState.CLOSED - return CurrentState.CLOSED - elif not out1_open and not out2_closed and self.last_stable_state == CurrentState.OPEN: - return CurrentState.CLOSING - elif not out1_open and not out2_closed and self.last_stable_state == CurrentState.CLOSED: - return CurrentState.OPENING - elif not out1_open and not out2_closed and self.last_stable_state == CurrentState.OPENING: - return CurrentState.OPENING - elif not out1_open and not out2_closed and self.last_stable_state == CurrentState.CLOSING: - return CurrentState.CLOSING - else: - self.last_stable_state = CurrentState.STOPPED - return CurrentState.STOPPED - - def _handle_input_event(self, event: typing.Any): - self._send_current_state_update(event) - - def _send_current_state_update(self, event): - state = self._get_current_gate_state() - # set targetdoorstate - if state == CurrentState.OPEN or state == CurrentState.OPENING or state == CurrentState.STOPPED: - target_state = TargetState.OPEN - else: - target_state = TargetState.CLOSED - # set currentdoorstate - # The following condition is a workaround for a homebridge bug: - # Sending OPENING leads to a push message to the phone that the gate is already open. - # Sending CLOSING while the gate is actually OPENING leads to the right message - # and inhibits the premature push message. - if state == CurrentState.OPENING: - current_state = CurrentState.CLOSING - else: - current_state = state - self._send_state(target_state, current_state) - - def _send_state(self, target_state: typing.Optional[TargetState], current_state: typing.Optional[CurrentState]): - params = {'accessoryId': config.accessory_id} - if target_state is not None: - params['targetdoorstate'] = target_state.value - if current_state is not None: - params['currentdoorstate'] = current_state.value - r = httpx.get(f'{config.webhook_url}', params=params) - logger.info("GET %s", r.url) - r.raise_for_status() - - async def _pulse_in1(self) -> None: - # FAAC-E124 Configuration - # IN 1: OPEN A (LO = E or EP) - self.hardware.set_relay(True) - await sleep(PULSE_LENGTH) - self.hardware.set_relay(False) - await sleep(PULSE_LENGTH) - return diff --git a/app/api/health_router.py b/app/api/health_router.py deleted file mode 100644 index 15853f3..0000000 --- a/app/api/health_router.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import APIRouter - -from app.api.health_service import HealthService -from app.api.models import Health - -health_router = APIRouter() -health_service = HealthService() - - -@health_router.get('', response_model=Health) -async def get_health(): - health = await health_service.get_health() - return health diff --git a/app/api/health_service.py b/app/api/health_service.py deleted file mode 100644 index 1e651f5..0000000 --- a/app/api/health_service.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -from typing import Tuple - -import psutil -import time - -from app.api.models import Health - - -class HealthService: - - async def get_health(self) -> Health: - load1, load5, load15 = os.getloadavg() - cpu_temp = self._get_cpu_temp() - total_mem, available_mem = self._get_total_available_mem() - system_uptime, process_uptime = self._get_uptimes_system_process() - - return Health( - load1=load1, - load5=load5, - load15=load15, - cpu_temp=cpu_temp, - total_mem=total_mem, - available_mem=available_mem, - system_uptime=system_uptime, - process_uptime=process_uptime - ) - - def _get_cpu_temp(self) -> float: - cpu_temp = None - known_cpu_devices = ["cpu_thermal", "k10temp"] - temperatures = psutil.sensors_temperatures() - cpu_device = next(iter([d for d in temperatures.keys() if d in known_cpu_devices]), None) - if cpu_device is not None: - cpu_temp = temperatures[cpu_device][0].current - return cpu_temp - - def _get_total_available_mem(self) -> Tuple[int, int]: - virtual_mem = psutil.virtual_memory() - return virtual_mem.total, virtual_mem.available - - def _get_uptimes_system_process(self) -> Tuple[float, float]: - now = time.time() - system_uptime = now - psutil.boot_time() - process = psutil.Process() - process_uptime = now - process.create_time() - return system_uptime, process_uptime diff --git a/app/api/index.py b/app/api/index.py deleted file mode 100644 index 224dc81..0000000 --- a/app/api/index.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi import APIRouter -from starlette.responses import RedirectResponse - -index = APIRouter() - - -@index.get('/') -async def redirect_to_gate(): - response = RedirectResponse(url='/gate') - return response diff --git a/app/api/logger.py b/app/api/logger.py deleted file mode 100644 index ad6fbc3..0000000 --- a/app/api/logger.py +++ /dev/null @@ -1,8 +0,0 @@ -import logging - -logger = logging.getLogger('gatecontrol') -# Note that the log level of the logger is set in the Config -ch = logging.StreamHandler() -formatter = logging.Formatter(logging.BASIC_FORMAT) -ch.setFormatter(formatter) -logger.addHandler(ch) diff --git a/app/api/models.py b/app/api/models.py deleted file mode 100644 index 302e459..0000000 --- a/app/api/models.py +++ /dev/null @@ -1,22 +0,0 @@ -from pydantic import BaseModel - -from app.api.gate_service import TargetState, CurrentState - - -class GateIn(BaseModel): - action: TargetState - - -class GateOut(BaseModel): - currentdoorstate: CurrentState - - -class Health(BaseModel): - load1: float - load5: float - load15: float - cpu_temp: float - total_mem: int - available_mem: int - system_uptime: float - process_uptime: float diff --git a/app/main.py b/app/main.py deleted file mode 100644 index be6411c..0000000 --- a/app/main.py +++ /dev/null @@ -1,20 +0,0 @@ -import uvicorn -from fastapi import FastAPI - -from app.api.config import config -from app.api.gate_router import gate_router -from app.api.health_router import health_router -from app.api.index import index - -app = FastAPI() -app.include_router(index, tags=['index']) -app.include_router(gate_router, prefix='/gate', tags=['gate']) -app.include_router(health_router, prefix='/health', tags=['health']) - - -def start_server(): - uvicorn.run(app, host=config.host, port=int(config.port)) - - -if __name__ == "__main__": - start_server() diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py deleted file mode 100644 index 7fadb95..0000000 --- a/app/tests/test_auth.py +++ /dev/null @@ -1,33 +0,0 @@ -import unittest - -from fastapi import HTTPException -from fastapi.security import HTTPBasicCredentials - -from app.api import auth -from app.api.config import config - - -class TestAuth(unittest.TestCase): - - def test_successful_authorize_request(self): - # setup - credentials = HTTPBasicCredentials(username="foo", password="bar") - config.basic_auth_username = "foo" - config.basic_auth_password = "bar" - # when - auth.authorize_request(credentials) - # then assert no exception was thrown - # cleanup - config.basic_auth_username = None - config.basic_auth_password = None - - def test_unsuccessful_authorize_request(self): - # setup - credentials = HTTPBasicCredentials(username="foo", password="bar") - config.basic_auth_username = "foo" - config.basic_auth_password = "incorrect" - # when/then - self.assertRaises(HTTPException, auth.authorize_request, credentials) - # cleanup - config.basic_auth_username = None - config.basic_auth_password = None diff --git a/app/tests/test_config.py b/app/tests/test_config.py deleted file mode 100644 index b3fe697..0000000 --- a/app/tests/test_config.py +++ /dev/null @@ -1,58 +0,0 @@ -import unittest - -from app.api.config import * - - -class TestConfig(unittest.TestCase): - - def test_default_config_exists(self): - # when/then - self.assertEqual(DEFAULT_HOST, config.host) - self.assertEqual(DEFAULT_PORT, config.port) - self.assertIsNone(config.basic_auth_username) - self.assertIsNone(config.basic_auth_password) - self.assertEqual(DEFAULT_WEBHOOK_URL, config.webhook_url) - self.assertEqual(DEFAULT_ACCESSORY_ID, config.accessory_id) - self.assertEqual(DEFAULT_SPI_BUS, config.spi_bus) - self.assertEqual(DEFAULT_SPI_CHIP_SELECT, config.spi_chip_select) - self.assertEqual(DEFAULT_SPI_HARDWARE_ADDR, config.spi_hardware_addr) - self.assertEqual(DEFAULT_DIDO_RELAY_PIN, config.dido_relay_pin) - self.assertFalse(config.is_basic_auth_active()) - - def test_config_reads_from_environment(self): - # setup - os.environ[HOST] = '127.0.0.1' - os.environ[PORT] = '8000' - os.environ[BASIC_AUTH_USERNAME] = 'user' - os.environ[BASIC_AUTH_PASSWORD] = 'password' - os.environ[WEBHOOK_URL] = 'http://testhook' - os.environ[ACCESSORY_ID] = 'testaccessory' - os.environ[SPI_BUS] = '1' - os.environ[SPI_CHIP_SELECT] = '1' - os.environ[SPI_HARDWARE_ADDR] = '2' - os.environ[DIDO_RELAY_PIN] = '3' - # when - test_config = Config() - # then - self.assertEqual('127.0.0.1', test_config.host) - self.assertEqual('8000', test_config.port) - self.assertEqual('user', test_config.basic_auth_username) - self.assertEqual('password', test_config.basic_auth_password) - self.assertEqual('http://testhook', test_config.webhook_url) - self.assertEqual('testaccessory', test_config.accessory_id) - self.assertEqual(1, test_config.spi_bus) - self.assertEqual(1, test_config.spi_chip_select) - self.assertEqual(2, test_config.spi_hardware_addr) - self.assertEqual(3, test_config.dido_relay_pin) - self.assertTrue(test_config.is_basic_auth_active()) - # cleanup - del os.environ[HOST] - del os.environ[PORT] - del os.environ[BASIC_AUTH_USERNAME] - del os.environ[BASIC_AUTH_PASSWORD] - del os.environ[WEBHOOK_URL] - del os.environ[ACCESSORY_ID] - del os.environ[SPI_BUS] - del os.environ[SPI_CHIP_SELECT] - del os.environ[SPI_HARDWARE_ADDR] - del os.environ[DIDO_RELAY_PIN] diff --git a/app/tests/test_gate_router.py b/app/tests/test_gate_router.py deleted file mode 100644 index 9fcee9d..0000000 --- a/app/tests/test_gate_router.py +++ /dev/null @@ -1,81 +0,0 @@ -import unittest -from base64 import b64encode -from unittest.mock import patch - -from app.tests.testing_utils import AsyncMock - -with patch('app.api.gate_service.GateService'): - import app.api.gate_router - -from fastapi.testclient import TestClient -from starlette import status - -from app.api.config import config -from app.api.gate_service import TargetState -from app.main import app - - -@patch('app.api.gate_router.gate_service', new_callable=AsyncMock) -class TestGateRouter(unittest.TestCase): - - def setUp(self) -> None: - self.client = TestClient(app) - - def test_gate_get(self, mock_gate_service): - # setup - mock_gate_service.get_current_gate_state.return_value = 1 - # when - response = self.client.get("/gate") - # then - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual({"currentdoorstate": 1}, response.json()) - mock_gate_service.get_current_gate_state.assert_called() - - @patch('fastapi.BackgroundTasks.add_task') - def test_gate_post(self, mock_add_task, mock_gate_service): - # when - response = self.client.post("/gate", json={"action": TargetState.CLOSED.value}) - # then - self.assertEqual(status.HTTP_200_OK, response.status_code) - mock_add_task.assert_called_with(mock_gate_service.request_gate_movement, TargetState.CLOSED) - - @patch('fastapi.BackgroundTasks.add_task') - def test_gate_post_invalid_action(self, mock_add_task, mock_gate_service): - # when - response = self.client.post("/gate", json={"action": -1}) - # then - self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, response.status_code) - mock_add_task.assert_not_called() - - @patch('fastapi.BackgroundTasks.add_task') - def test_gate_post_authenticated(self, mock_add_task, mock_gate_service): - # setup - config.basic_auth_username = "user" - config.basic_auth_password = "password" - # when - response = self.client.post("/gate", json={"action": TargetState.OPEN.value}, - headers={"Authorization": self.get_basic_auth("user", "password")}) - # then - self.assertEqual(status.HTTP_200_OK, response.status_code) - mock_add_task.assert_called_with(mock_gate_service.request_gate_movement, TargetState.OPEN) - # cleanup - config.basic_auth_username = None - config.basic_auth_password = None - - def test_gate_post_invalid_authentication(self, mock_gate_service): - # setup - config.basic_auth_username = "user" - config.basic_auth_password = "password" - # when - response = self.client.post("/gate", json={"action": 0}, - headers={"Authorization": self.get_basic_auth("user", "incorrect")}) - # then - self.assertEqual(status.HTTP_401_UNAUTHORIZED, response.status_code) - mock_gate_service.request_gate_movement.assert_not_called() - # cleanup - config.basic_auth_username = None - config.basic_auth_password = None - - def get_basic_auth(self, username: str, password: str) -> str: - user_and_pass = b64encode(f"{username}:{password}".encode('utf-8')).decode("ascii") - return "Basic {}".format(user_and_pass) diff --git a/app/tests/test_gate_service.py b/app/tests/test_gate_service.py deleted file mode 100644 index 93e4f95..0000000 --- a/app/tests/test_gate_service.py +++ /dev/null @@ -1,119 +0,0 @@ -from unittest.mock import patch, MagicMock, call - -from aiounittest import AsyncTestCase - -from app.api.config import DEFAULT_WEBHOOK_URL, DEFAULT_ACCESSORY_ID -from app.api.gate_service import GateService, TargetState, CurrentState - - -@patch('app.api.gate_service.httpx') -class TestGateService(AsyncTestCase): - - def setUp(self) -> None: - self.hardware_mock = MagicMock() - self.hardware_mock.supports_inputs = False - self.gate_service = GateService(hardware=self.hardware_mock) - - def test_hardware_initialization_without_inputs(self, mock_httpx): - self.hardware_mock.start.assert_not_called() - self.assertEqual(CurrentState.STOPPED, self.gate_service.last_stable_state) - - async def test_request_gate_movement_open_to_open(self, mock_httpx): - # setup - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.OPEN) - # when - await self.gate_service.request_gate_movement(TargetState.OPEN) - # then - mock_httpx.get.assert_not_called() - self.hardware_mock.set_relay.assert_not_called() - - async def test_request_gate_movement_stopped_to_open(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.STOPPED) - # when - await self.gate_service.request_gate_movement(TargetState.OPEN) - # then - mock_httpx.get.assert_called_once_with( - DEFAULT_WEBHOOK_URL, - params={ - 'accessoryId': DEFAULT_ACCESSORY_ID, - 'targetdoorstate': TargetState.OPEN.value, - 'currentdoorstate': CurrentState.OPEN.value - } - ) - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - self.assertEqual(CurrentState.OPEN, self.gate_service.last_stable_state) - - async def test_request_gate_movement_closed_to_closed(self, mock_httpx): - # setup - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.CLOSED) - # when - await self.gate_service.request_gate_movement(TargetState.CLOSED) - # then - mock_httpx.get.assert_not_called() - self.hardware_mock.set_relay.assert_not_called() - - async def test_request_gate_movement_open_to_closed(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.OPEN) - # when - await self.gate_service.request_gate_movement(TargetState.CLOSED) - # then - mock_httpx.get.assert_called_once_with( - DEFAULT_WEBHOOK_URL, - params={ - 'accessoryId': DEFAULT_ACCESSORY_ID, - 'targetdoorstate': TargetState.CLOSED.value, - 'currentdoorstate': CurrentState.CLOSED.value - } - ) - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - self.assertEqual(CurrentState.CLOSED, self.gate_service.last_stable_state) - - async def test_request_gate_movement_opening_to_open(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.OPENING) - # when - await self.gate_service.request_gate_movement(TargetState.OPEN) - # then - mock_httpx.get.assert_called_once() - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - - async def test_request_gate_movement_closed_to_open(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.CLOSED) - # when - await self.gate_service.request_gate_movement(TargetState.OPEN) - # then - mock_httpx.get.assert_called_once() - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - - async def test_request_gate_movement_closing_to_closed(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.CLOSING) - # when - await self.gate_service.request_gate_movement(TargetState.CLOSED) - # then - mock_httpx.get.assert_called_once() - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - - async def test_request_gate_movement_closing_to_open(self, mock_httpx): - # setup - mock_httpx.get = MagicMock(return_value=MagicMock()) - self.gate_service._get_current_gate_state = MagicMock(return_value=CurrentState.CLOSING) - # when - await self.gate_service.request_gate_movement(TargetState.OPEN) - # then - mock_httpx.get.assert_called_once() - self.hardware_mock.set_relay.assert_has_calls([call(True), call(False)]) - - async def test_get_current_gate_state(self, mock_httpx): - # when - current_state = await self.gate_service.get_current_gate_state() - # then - self.assertEqual(CurrentState.STOPPED, current_state) diff --git a/app/tests/test_health_router.py b/app/tests/test_health_router.py deleted file mode 100644 index 9a248be..0000000 --- a/app/tests/test_health_router.py +++ /dev/null @@ -1,44 +0,0 @@ -import unittest -from unittest.mock import patch - -from fastapi.testclient import TestClient -from starlette import status - -from app.api.models import Health - -with patch('app.api.gate_service.GateService'): - from app.main import app -from app.tests.testing_utils import AsyncMock - - -@patch('app.api.health_router.health_service', new_callable=AsyncMock) -class TestHealthRouter(unittest.TestCase): - - def setUp(self) -> None: - self.client = TestClient(app) - - def test_gate_health(self, mock_health_service): - # setup - mock_health_service.get_health.return_value = Health( - load1=1.0, - load5=0.5, - load15=0.1, - cpu_temp=51.0, - total_mem=8282419200, - available_mem=7638609920, - system_uptime=1000.0, - process_uptime=800.0 - ) - # when - response = self.client.get("/health") - # then - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual({'load1': 1.0, - 'load5': 0.5, - 'load15': 0.1, - 'cpu_temp': 51.0, - 'total_mem': 8282419200, - 'available_mem': 7638609920, - 'system_uptime': 1000.0, - 'process_uptime': 800.0}, response.json()) - mock_health_service.get_health.assert_called() diff --git a/app/tests/test_health_service.py b/app/tests/test_health_service.py deleted file mode 100644 index ef0552a..0000000 --- a/app/tests/test_health_service.py +++ /dev/null @@ -1,74 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import patch, MagicMock - -from aiounittest import AsyncTestCase - -from app.api.health_service import HealthService -from app.api.models import Health - - -@patch('app.api.health_service.os') -@patch('app.api.health_service.psutil') -@patch('app.api.health_service.time') -class TestHealthService(AsyncTestCase): - - def setUp(self) -> None: - self.health_service = HealthService() - - async def test_get_health(self, mock_time, mock_psutil, mock_os): - # setup - # mock os.getloadavg() - mock_os.getloadavg = MagicMock(return_value=(1.0, 0.5, 0.1)) - - # mock psutil.sensor_temperatures() - temp = SimpleNamespace(label='', current=51.0, high=None, critical=None) - mock_psutil.sensors_temperatures = MagicMock(return_value={"cpu_thermal": [temp]}) - - # mock psutil.virtual_memory() - mem = SimpleNamespace( - total=8282419200, - available=7638609920, - percent=7.8, - used=342773760, - free=7189024768, - active=283893760, - inactive=540917760, - buffers=150794240, - cached=599826432, - shared=39407616, - slab=206086144 - ) - mock_psutil.virtual_memory = MagicMock(return_value=mem) - - # mock time.time() - mock_time.time = MagicMock(return_value=2000.0) - - # mock psutil.boot_time() - mock_psutil.boot_time = MagicMock(return_value=1000.0) - - # mock psutil.Process() - mock_process = MagicMock() - mock_process.create_time = MagicMock(return_value=1200.0) - mock_psutil.Process = MagicMock(return_value=mock_process) - - # when - health = await self.health_service.get_health() - - # then - mock_os.getloadavg.assert_called_once() - mock_psutil.sensors_temperatures.assert_called_once() - mock_psutil.virtual_memory.assert_called_once() - mock_time.time.assert_called_once() - mock_psutil.boot_time.assert_called_once() - mock_psutil.Process.assert_called_once() - mock_process.create_time.assert_called_once() - self.assertEqual(health, Health( - load1=1.0, - load5=0.5, - load15=0.1, - cpu_temp=51.0, - total_mem=8282419200, - available_mem=7638609920, - system_uptime=1000.0, - process_uptime=800.0 - )) diff --git a/app/tests/test_index.py b/app/tests/test_index.py deleted file mode 100644 index 46af9b3..0000000 --- a/app/tests/test_index.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest -from unittest.mock import patch - -from fastapi.testclient import TestClient -from starlette import status - -with patch('app.api.gate_service.GateService'): - import app.api.gate_router - -from app.main import app - - -class TestIndexRouter(unittest.TestCase): - - def setUp(self) -> None: - self.client = TestClient(app) - - def test_index_get(self): - # when - response = self.client.get("/", follow_redirects=False) - # then - self.assertEqual(status.HTTP_307_TEMPORARY_REDIRECT, response.status_code) - self.assertEqual("/gate", response.headers['location']) diff --git a/app/tests/test_main.py b/app/tests/test_main.py deleted file mode 100644 index 22cc898..0000000 --- a/app/tests/test_main.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import unittest -from unittest.mock import patch - -from app.api.config import HOST, DEFAULT_HOST, PORT, DEFAULT_PORT, config - - -class TestAuth(unittest.TestCase): - - def test_init_with_host_port(self): - # setup - os.environ[HOST] = '10.0.0.1' - os.environ[PORT] = '1234' - config.reload() - with patch('app.api.gate_service.GateService'): - from app import main - with patch.object(main, "uvicorn") as uvicorn_run_mock: - with patch.object(main, "__name__", "__main__"): - # when - main.start_server() - # then - uvicorn_run_mock.run.assert_called_once_with(main.app, host='10.0.0.1', port=int('1234')) - # cleanup - del os.environ[HOST] - del os.environ[PORT] - config.reload() - - def test_init_default_host_port(self): - # setup - with patch('app.api.gate_service.GateService'): - from app import main - with patch.object(main, "uvicorn") as uvicorn_run_mock: - with patch.object(main, "__name__", "__main__"): - # when - main.start_server() - # then - uvicorn_run_mock.run.assert_called_once_with(main.app, host=DEFAULT_HOST, port=int(DEFAULT_PORT)) diff --git a/app/tests/testing_utils.py b/app/tests/testing_utils.py deleted file mode 100644 index a03136a..0000000 --- a/app/tests/testing_utils.py +++ /dev/null @@ -1,7 +0,0 @@ -from unittest.mock import MagicMock - - -class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) - diff --git a/cmd/gatecontrol/main.go b/cmd/gatecontrol/main.go new file mode 100644 index 0000000..d013690 --- /dev/null +++ b/cmd/gatecontrol/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "gatecontrol/internal/app" + "gatecontrol/internal/config" + "gatecontrol/internal/dido" + "gatecontrol/internal/domain" + "gatecontrol/internal/mocks" + mqttadapter "gatecontrol/internal/mqtt" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(1) + } + + logger := newLogger(cfg.LogLevel) + + var hardware domain.GateHardware + if cfg.UseMockDIDO { + hardware = mocks.NewMockDIDO(cfg.PulseLength) + logger.Info("using mock DIDO hardware") + } else { + hardware, err = dido.Open(dido.Config{ + Bus: cfg.SPIBus, + ChipSelect: cfg.SPIChipSelect, + HardwareAddr: cfg.SPIHardwareAddr, + RelayPin: cfg.DIDORelayPin, + SpeedHz: cfg.SPISpeedHz, + PulseLength: cfg.PulseLength, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "dido init error: %v\n", err) + os.Exit(1) + } + logger.Info( + "using DIDO SPI hardware", + "bus", cfg.SPIBus, + "chip_select", cfg.SPIChipSelect, + "hardware_addr", cfg.SPIHardwareAddr, + "relay_pin", cfg.DIDORelayPin, + "speed_hz", cfg.SPISpeedHz, + ) + } + + var commandSub domain.CommandSubscriber + var statePub domain.StatePublisher + if cfg.UseMockMQTT { + mock := mocks.NewMockMQTT() + commandSub = mock + statePub = mock + logger.Info("using mock MQTT adapter") + } else { + brokerURL := fmt.Sprintf("tcp://%s:%d", cfg.MQTTBroker, cfg.MQTTPort) + client, err := mqttadapter.New(mqttadapter.Config{ + BrokerURL: brokerURL, + ClientID: cfg.MQTTClientID, + Username: cfg.MQTTUsername, + Password: cfg.MQTTPassword, + BaseTopic: cfg.MQTTBase, + }, logger) + if err != nil { + fmt.Fprintf(os.Stderr, "mqtt init error: %v\n", err) + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := client.Connect(ctx); err != nil { + cancel() + fmt.Fprintf(os.Stderr, "mqtt connect error: %v\n", err) + os.Exit(1) + } + cancel() + + commandSub = client + statePub = client + } + + svc := app.NewService(hardware, commandSub, statePub, logger, cfg.InitialState) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := svc.Run(ctx); err != nil { + logger.Error("service exited with error", "err", err) + os.Exit(1) + } +} + +func newLogger(level string) *slog.Logger { + var l slog.Level + switch level { + case "debug": + l = slog.LevelDebug + case "warn": + l = slog.LevelWarn + case "error": + l = slog.LevelError + default: + l = slog.LevelInfo + } + h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: l}) + return slog.New(h) +} diff --git a/documentation/HttpWebHooks.json b/documentation/HttpWebHooks.json deleted file mode 100644 index 7494437..0000000 --- a/documentation/HttpWebHooks.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "webhook_port": "51828", - "garagedooropeners": [ - { - "id": "entrancegate", - "name": "Entrance", - "open_url": "http://ip_address_of_pi_zero:8000/gate", - "open_method": "POST", - "open_body": "{\"action\": 0}", - "open_headers": "{\"Authorization\": \"Basic xxxxxxxxxxxxxxxxxxxxxxxxxx==\", \"Content-Type\": \"application/json\"}", - "close_url": "http://ip_address_of_pi_zero:8000/gate", - "close_method": "POST", - "close_body": "{\"action\": 1}", - "close_headers": "{\"Authorization\": \"Basic xxxxxxxxxxxxxxxxxxxxxxxxxx==\", \"Content-Type\": \"application/json\"}" - } - ], - "platform": "HttpWebHooks" -} diff --git a/documentation/architecture.puml b/documentation/architecture.puml index c78c36d..466cd16 100644 --- a/documentation/architecture.puml +++ b/documentation/architecture.puml @@ -14,9 +14,10 @@ package "Home Network" as home { node "home hub\l(e.g. Apple TV)" as home_hub #white node "Raspberry Pi" as pi #white { [Homebridge\lService] as homebridge + [MQTT\lBroker] as mqtt } node "Raspberry Pi Zero" as pi_zero #white { - [gatecontrol\lweb service] as gatecontrol + [gatecontrol\lGo service] as gatecontrol } } @@ -28,8 +29,9 @@ user -right-> homeapp homeapp <-right-> homekit_service homekit_service <--> home_hub home_hub <--> homebridge -homebridge <--> gatecontrol: homebridge-\lhttp-\lwebhooks\lplugin -gatecontrol <--> e124: interfaces with\lDIDO module and\lOptocoupler +homebridge <--> mqtt: MQTT accessory\lplugin +mqtt <--> gatecontrol: command,\lstate,\lavailability +gatecontrol <--> e124: pulses IN 1\lvia DIDO module\land Optocoupler e124 <-right-> gate: controls -@enduml \ No newline at end of file +@enduml diff --git a/documentation/gatecontrol.service b/documentation/gatecontrol.service index ac105f2..cc3d81e 100644 --- a/documentation/gatecontrol.service +++ b/documentation/gatecontrol.service @@ -1,23 +1,28 @@ [Unit] Description=Gate Control -After=multi-user.target +After=network-online.target +Wants=network-online.target [Service] Type=simple User=pi Group=pi -Environment="PYTHONPATH=/home/pi/gatecontrol" -Environment="WEBHOOK_URL=http://ip_address_of_homebridge:51828" -Environment="BASIC_AUTH_USERNAME=user_chosen_for_basic_auth" -Environment="BASIC_AUTH_PASSWORD=password_chosen_for_basic_auth" -Environment="ACCESSORY_ID=entrancegate" -Environment="LOG_LEVEL=INFO" +Environment="MQTT_BROKER=ip_address_of_mqtt_broker" +Environment="MQTT_PORT=1883" +Environment="MQTT_CLIENT_ID=gatecontrol" +Environment="MQTT_USERNAME=faacuser" +Environment="MQTT_PASSWORD=replace_me" +Environment="MQTT_BASE_TOPIC=faac/gate" +Environment="LOG_LEVEL=info" Environment="SPI_BUS=0" Environment="SPI_CHIP_SELECT=0" Environment="SPI_HARDWARE_ADDR=0" Environment="DIDO_RELAY_PIN=0" -ExecStart=/usr/bin/python3 /home/pi/gatecontrol/app/main.py -Restart=on-abort +Environment="SPI_SPEED_HZ=100000" +Environment="PULSE_LENGTH_MS=500" +ExecStart=/home/pi/gatecontrol/gatecontrol +Restart=on-failure +RestartSec=5 [Install] WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a7d1a3 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module gatecontrol + +go 1.25.0 + +require ( + github.com/eclipse/paho.mqtt.golang v1.5.1 + periph.io/x/conn/v3 v3.7.3 + periph.io/x/host/v3 v3.8.5 +) + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c24cb39 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +periph.io/x/conn/v3 v3.7.3 h1:+8UblkC4omTB1M+jZTvTj3qoxQOTJy0ZRQm8DLUuVzc= +periph.io/x/conn/v3 v3.7.3/go.mod h1:tyV9YaYquOJ2Q2yAL0B5zk9ZvHGsbW56M6y92wjyPDQ= +periph.io/x/host/v3 v3.8.5 h1:g4g5xE1XZtDiGl1UAJaUur1aT7uNiFLMkyMEiZ7IHII= +periph.io/x/host/v3 v3.8.5/go.mod h1:hPq8dISZIc+UNfWoRj+bPH3XEBQqJPdFdx218W92mdc= diff --git a/internal/app/service.go b/internal/app/service.go new file mode 100644 index 0000000..dc9a41d --- /dev/null +++ b/internal/app/service.go @@ -0,0 +1,129 @@ +package app + +import ( + "context" + "log/slog" + + "gatecontrol/internal/domain" +) + +// Service orchestrates MQTT commands and DIDO relay pulses. +type Service struct { + hardware domain.GateHardware + commands domain.CommandSubscriber + states domain.StatePublisher + logger *slog.Logger + + assumedState domain.GateState +} + +// NewService constructs the service with injected adapters. +func NewService( + hardware domain.GateHardware, + commands domain.CommandSubscriber, + states domain.StatePublisher, + logger *slog.Logger, + initialState domain.GateState, +) *Service { + if logger == nil { + logger = slog.Default() + } + if initialState == "" || initialState == domain.StateUnknown { + initialState = domain.StateClosed + } + return &Service{ + hardware: hardware, + commands: commands, + states: states, + logger: logger, + assumedState: initialState, + } +} + +// Run executes until ctx is canceled. +func (s *Service) Run(ctx context.Context) error { + defer s.closeAll() + + if err := s.states.PublishAvailability(ctx, true); err != nil { + s.logger.Warn("publish availability", "err", err) + } + if err := s.states.PublishState(ctx, s.assumedState); err != nil { + s.logger.Warn("publish initial state", "state", s.assumedState, "err", err) + } + + cmdCh := make(chan domain.Command, 8) + if err := s.commands.SubscribeCommands(ctx, func(cmd domain.Command) error { + select { + case cmdCh <- cmd: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }); err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + _ = s.states.PublishAvailability(context.Background(), false) + return nil + case cmd := <-cmdCh: + s.handleCommand(ctx, cmd) + } + } +} + +func (s *Service) handleCommand(ctx context.Context, cmd domain.Command) { + targetState, ok := targetStateForCommand(cmd) + if !ok { + s.logger.Warn("invalid command", "command", cmd) + return + } + + if s.assumedState == targetState { + s.logger.Debug("skipping command because assumed state already matches target", "command", cmd, "state", s.assumedState) + return + } + + if err := s.states.PublishTargetState(ctx, cmd); err != nil { + s.logger.Warn("publish target state", "target", cmd, "err", err) + } + + if err := s.hardware.Pulse(ctx); err != nil { + s.logger.Error("pulse relay", "command", cmd, "err", err) + _ = s.states.PublishAvailability(ctx, false) + return + } + + s.assumedState = targetState + if err := s.states.PublishState(ctx, targetState); err != nil { + s.logger.Warn("publish state", "state", targetState, "err", err) + } + if err := s.states.PublishAvailability(ctx, true); err != nil { + s.logger.Warn("publish availability", "err", err) + } +} + +func targetStateForCommand(cmd domain.Command) (domain.GateState, bool) { + switch cmd { + case domain.CommandOpen: + return domain.StateOpen, true + case domain.CommandClose: + return domain.StateClosed, true + default: + return "", false + } +} + +func (s *Service) closeAll() { + if err := s.hardware.Close(); err != nil { + s.logger.Debug("close hardware", "err", err) + } + if err := s.commands.Close(); err != nil { + s.logger.Debug("close command subscriber", "err", err) + } + if err := s.states.Close(); err != nil { + s.logger.Debug("close state publisher", "err", err) + } +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go new file mode 100644 index 0000000..37d7a50 --- /dev/null +++ b/internal/app/service_test.go @@ -0,0 +1,270 @@ +package app + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "gatecontrol/internal/domain" + "gatecontrol/internal/mocks" +) + +func TestServicePublishesInitialClosedState(t *testing.T) { + t.Parallel() + + hardware := mocks.NewMockDIDO(time.Millisecond) + mqtt := mocks.NewMockMQTT() + svc := newTestService(hardware, mqtt) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := runService(ctx, svc) + waitForSubscription(t, mqtt) + cancel() + if err := <-errCh; err != nil { + t.Fatalf("run: %v", err) + } + + states := mqtt.States() + if len(states) == 0 || states[0] != domain.StateClosed { + t.Fatalf("expected initial CLOSED state, got %v", states) + } + availability := mqtt.Availability() + if len(availability) < 2 || !availability[0] || availability[len(availability)-1] { + t.Fatalf("expected online then offline availability, got %v", availability) + } + if !hardware.Closed() || !mqtt.Closed() { + t.Fatalf("expected adapters to be closed") + } +} + +func TestServiceOpenFromClosedPulsesOnce(t *testing.T) { + t.Parallel() + + hardware := mocks.NewMockDIDO(time.Millisecond) + mqtt := mocks.NewMockMQTT() + svc := newTestService(hardware, mqtt) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := runService(ctx, svc) + waitForSubscription(t, mqtt) + + if err := mqtt.Inject(domain.CommandOpen); err != nil { + t.Fatalf("inject open: %v", err) + } + waitForPulses(t, hardware, 1) + waitForState(t, mqtt, domain.StateOpen) + cancel() + if err := <-errCh; err != nil { + t.Fatalf("run: %v", err) + } + + if got := hardware.Pulses(); got != 1 { + t.Fatalf("pulses=%d", got) + } + assertPrefixTransitions(t, hardware.Transitions(), []bool{true, false}) + assertContainsState(t, mqtt.States(), domain.StateOpen) + assertTargets(t, mqtt.Targets(), []domain.Command{domain.CommandOpen}) +} + +func TestServiceDuplicateOpenDoesNotPulse(t *testing.T) { + t.Parallel() + + hardware := mocks.NewMockDIDO(time.Millisecond) + mqtt := mocks.NewMockMQTT() + svc := newTestService(hardware, mqtt) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := runService(ctx, svc) + waitForSubscription(t, mqtt) + + _ = mqtt.Inject(domain.CommandOpen) + waitForPulses(t, hardware, 1) + waitForState(t, mqtt, domain.StateOpen) + _ = mqtt.Inject(domain.CommandOpen) + time.Sleep(20 * time.Millisecond) + cancel() + if err := <-errCh; err != nil { + t.Fatalf("run: %v", err) + } + + if got := hardware.Pulses(); got != 1 { + t.Fatalf("expected one pulse for duplicate open, got %d", got) + } + assertTargets(t, mqtt.Targets(), []domain.Command{domain.CommandOpen}) +} + +func TestServiceCloseFromOpenPulsesOnce(t *testing.T) { + t.Parallel() + + hardware := mocks.NewMockDIDO(time.Millisecond) + mqtt := mocks.NewMockMQTT() + svc := newTestService(hardware, mqtt) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := runService(ctx, svc) + waitForSubscription(t, mqtt) + + _ = mqtt.Inject(domain.CommandOpen) + waitForPulses(t, hardware, 1) + waitForState(t, mqtt, domain.StateOpen) + _ = mqtt.Inject(domain.CommandClose) + waitForPulses(t, hardware, 2) + waitForStateCount(t, mqtt, domain.StateClosed, 2) + cancel() + if err := <-errCh; err != nil { + t.Fatalf("run: %v", err) + } + + if got := hardware.Pulses(); got != 2 { + t.Fatalf("pulses=%d", got) + } + assertTargets(t, mqtt.Targets(), []domain.Command{domain.CommandOpen, domain.CommandClose}) + assertContainsState(t, mqtt.States(), domain.StateClosed) +} + +func TestServicePulseFailurePublishesOfflineAndKeepsState(t *testing.T) { + t.Parallel() + + hardware := mocks.NewMockDIDO(time.Millisecond) + hardware.SetFailPulse(true) + mqtt := mocks.NewMockMQTT() + svc := newTestService(hardware, mqtt) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := runService(ctx, svc) + waitForSubscription(t, mqtt) + + _ = mqtt.Inject(domain.CommandOpen) + waitForAvailabilityCount(t, mqtt, 2) + cancel() + if err := <-errCh; err != nil { + t.Fatalf("run: %v", err) + } + + if got := hardware.Pulses(); got != 0 { + t.Fatalf("expected no completed pulse, got %d", got) + } + states := mqtt.States() + if len(states) != 1 || states[0] != domain.StateClosed { + t.Fatalf("expected state to remain CLOSED only, got %v", states) + } + availability := mqtt.Availability() + if len(availability) < 2 || availability[1] { + t.Fatalf("expected offline after pulse failure, got %v", availability) + } +} + +func newTestService(hardware *mocks.MockDIDO, mqtt *mocks.MockMQTT) *Service { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewService(hardware, mqtt, mqtt, logger, domain.StateClosed) +} + +func runService(ctx context.Context, svc *Service) <-chan error { + errCh := make(chan error, 1) + go func() { errCh <- svc.Run(ctx) }() + return errCh +} + +func waitForSubscription(t *testing.T, mqtt *mocks.MockMQTT) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if mqtt.HandlerReady() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("subscription not ready") +} + +func waitForPulses(t *testing.T, hardware *mocks.MockDIDO, want int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if hardware.Pulses() >= want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timeout waiting for %d pulses, got %d", want, hardware.Pulses()) +} + +func waitForAvailabilityCount(t *testing.T, mqtt *mocks.MockMQTT, want int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if len(mqtt.Availability()) >= want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timeout waiting for %d availability messages, got %v", want, mqtt.Availability()) +} + +func waitForState(t *testing.T, mqtt *mocks.MockMQTT, want domain.GateState) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + for _, state := range mqtt.States() { + if state == want { + return + } + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timeout waiting for state %s, got %v", want, mqtt.States()) +} + +func waitForStateCount(t *testing.T, mqtt *mocks.MockMQTT, want domain.GateState, count int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + seen := 0 + for _, state := range mqtt.States() { + if state == want { + seen++ + } + } + if seen >= count { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timeout waiting for %d state %s messages, got %v", count, want, mqtt.States()) +} + +func assertPrefixTransitions(t *testing.T, got []bool, want []bool) { + t.Helper() + if len(got) < len(want) { + t.Fatalf("transitions too short: got=%v want prefix=%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("transition[%d]=%v want=%v; all=%v", i, got[i], want[i], got) + } + } +} + +func assertContainsState(t *testing.T, states []domain.GateState, want domain.GateState) { + t.Helper() + for _, state := range states { + if state == want { + return + } + } + t.Fatalf("missing state %s in %v", want, states) +} + +func assertTargets(t *testing.T, got []domain.Command, want []domain.Command) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("targets=%v want=%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("targets=%v want=%v", got, want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c16b4af --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "gatecontrol/internal/domain" +) + +// Config holds runtime settings loaded from environment variables. +type Config struct { + MQTTBroker string + MQTTPort int + MQTTClientID string + MQTTUsername string + MQTTPassword string + MQTTBase string + UseMockMQTT bool + + SPIBus int + SPIChipSelect int + SPIHardwareAddr int + DIDORelayPin int + SPISpeedHz int + PulseLength time.Duration + UseMockDIDO bool + + InitialState domain.GateState + LogLevel string +} + +// Load reads environment variables, applies defaults, and validates basics. +func Load() (Config, error) { + cfg := Config{ + MQTTBroker: envOrDefault("MQTT_BROKER", "localhost"), + MQTTPort: envIntOrDefault("MQTT_PORT", 1883), + MQTTClientID: envOrDefault("MQTT_CLIENT_ID", "gatecontrol"), + MQTTUsername: strings.TrimSpace(os.Getenv("MQTT_USERNAME")), + MQTTPassword: strings.TrimSpace(os.Getenv("MQTT_PASSWORD")), + MQTTBase: envOrDefault("MQTT_BASE_TOPIC", "faac/gate"), + UseMockMQTT: envBoolOrDefault("USE_MOCK_MQTT", false), + SPIBus: envIntOrDefault("SPI_BUS", 0), + SPIChipSelect: envIntOrDefault("SPI_CHIP_SELECT", 0), + SPIHardwareAddr: envIntOrDefault("SPI_HARDWARE_ADDR", 0), + DIDORelayPin: envIntOrDefault("DIDO_RELAY_PIN", 0), + SPISpeedHz: envIntOrDefault("SPI_SPEED_HZ", 100000), + PulseLength: time.Duration(envIntOrDefault("PULSE_LENGTH_MS", 500)) * time.Millisecond, + UseMockDIDO: envBoolOrDefault("USE_MOCK_DIDO", false), + InitialState: domain.StateClosed, + LogLevel: strings.ToLower(envOrDefault("LOG_LEVEL", "info")), + } + + if cfg.MQTTBroker == "" { + return Config{}, fmt.Errorf("MQTT_BROKER must not be empty") + } + if cfg.MQTTPort <= 0 { + return Config{}, fmt.Errorf("MQTT_PORT must be > 0") + } + if cfg.MQTTBase == "" { + return Config{}, fmt.Errorf("MQTT_BASE_TOPIC must not be empty") + } + if cfg.SPIBus < 0 { + return Config{}, fmt.Errorf("SPI_BUS must be >= 0") + } + if cfg.SPIChipSelect < 0 { + return Config{}, fmt.Errorf("SPI_CHIP_SELECT must be >= 0") + } + if cfg.SPIHardwareAddr < 0 || cfg.SPIHardwareAddr > 7 { + return Config{}, fmt.Errorf("SPI_HARDWARE_ADDR must be between 0 and 7") + } + if cfg.DIDORelayPin < 0 || cfg.DIDORelayPin > 7 { + return Config{}, fmt.Errorf("DIDO_RELAY_PIN must be between 0 and 7") + } + if cfg.SPISpeedHz <= 0 { + return Config{}, fmt.Errorf("SPI_SPEED_HZ must be > 0") + } + if cfg.PulseLength <= 0 { + return Config{}, fmt.Errorf("PULSE_LENGTH_MS must be > 0") + } + return cfg, nil +} + +func envOrDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func envIntOrDefault(key string, fallback int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func envBoolOrDefault(key string, fallback bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if v == "" { + return fallback + } + switch v { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..affd456 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "testing" + "time" + + "gatecontrol/internal/domain" +) + +func TestLoadDefaults(t *testing.T) { + t.Setenv("MQTT_BROKER", "") + t.Setenv("MQTT_PORT", "") + t.Setenv("MQTT_CLIENT_ID", "") + t.Setenv("MQTT_USERNAME", "") + t.Setenv("MQTT_PASSWORD", "") + t.Setenv("MQTT_BASE_TOPIC", "") + t.Setenv("USE_MOCK_MQTT", "") + t.Setenv("SPI_BUS", "") + t.Setenv("SPI_CHIP_SELECT", "") + t.Setenv("SPI_HARDWARE_ADDR", "") + t.Setenv("DIDO_RELAY_PIN", "") + t.Setenv("SPI_SPEED_HZ", "") + t.Setenv("PULSE_LENGTH_MS", "") + t.Setenv("USE_MOCK_DIDO", "") + t.Setenv("LOG_LEVEL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("load defaults: %v", err) + } + + if cfg.MQTTBroker != "localhost" { + t.Fatalf("MQTTBroker=%q", cfg.MQTTBroker) + } + if cfg.MQTTPort != 1883 { + t.Fatalf("MQTTPort=%d", cfg.MQTTPort) + } + if cfg.MQTTClientID != "gatecontrol" { + t.Fatalf("MQTTClientID=%q", cfg.MQTTClientID) + } + if cfg.MQTTBase != "faac/gate" { + t.Fatalf("MQTTBase=%q", cfg.MQTTBase) + } + if cfg.SPIBus != 0 || cfg.SPIChipSelect != 0 || cfg.SPIHardwareAddr != 0 || cfg.DIDORelayPin != 0 { + t.Fatalf("unexpected spi defaults: %+v", cfg) + } + if cfg.SPISpeedHz != 100000 { + t.Fatalf("SPISpeedHz=%d", cfg.SPISpeedHz) + } + if cfg.PulseLength != 500*time.Millisecond { + t.Fatalf("PulseLength=%s", cfg.PulseLength) + } + if cfg.InitialState != domain.StateClosed { + t.Fatalf("InitialState=%s", cfg.InitialState) + } +} + +func TestLoadFromEnvironment(t *testing.T) { + t.Setenv("MQTT_BROKER", "broker") + t.Setenv("MQTT_PORT", "1884") + t.Setenv("MQTT_CLIENT_ID", "client") + t.Setenv("MQTT_USERNAME", "user") + t.Setenv("MQTT_PASSWORD", "pass") + t.Setenv("MQTT_BASE_TOPIC", "test/gate") + t.Setenv("USE_MOCK_MQTT", "true") + t.Setenv("SPI_BUS", "1") + t.Setenv("SPI_CHIP_SELECT", "2") + t.Setenv("SPI_HARDWARE_ADDR", "3") + t.Setenv("DIDO_RELAY_PIN", "4") + t.Setenv("SPI_SPEED_HZ", "200000") + t.Setenv("PULSE_LENGTH_MS", "25") + t.Setenv("USE_MOCK_DIDO", "true") + t.Setenv("LOG_LEVEL", "debug") + + cfg, err := Load() + if err != nil { + t.Fatalf("load env: %v", err) + } + + if cfg.MQTTBroker != "broker" || cfg.MQTTPort != 1884 || cfg.MQTTClientID != "client" { + t.Fatalf("unexpected mqtt config: %+v", cfg) + } + if cfg.MQTTUsername != "user" || cfg.MQTTPassword != "pass" || cfg.MQTTBase != "test/gate" { + t.Fatalf("unexpected mqtt credentials/topic: %+v", cfg) + } + if !cfg.UseMockMQTT || !cfg.UseMockDIDO { + t.Fatalf("expected mock flags true: %+v", cfg) + } + if cfg.SPIBus != 1 || cfg.SPIChipSelect != 2 || cfg.SPIHardwareAddr != 3 || cfg.DIDORelayPin != 4 { + t.Fatalf("unexpected spi config: %+v", cfg) + } + if cfg.SPISpeedHz != 200000 || cfg.PulseLength != 25*time.Millisecond || cfg.LogLevel != "debug" { + t.Fatalf("unexpected misc config: %+v", cfg) + } +} diff --git a/internal/dido/periph.go b/internal/dido/periph.go new file mode 100644 index 0000000..58c3d67 --- /dev/null +++ b/internal/dido/periph.go @@ -0,0 +1,58 @@ +package dido + +import ( + "fmt" + "time" + + "periph.io/x/conn/v3/physic" + "periph.io/x/conn/v3/spi" + "periph.io/x/conn/v3/spi/spireg" + "periph.io/x/host/v3" +) + +// Config contains the SPI settings for the DIDO board. +type Config struct { + Bus int + ChipSelect int + HardwareAddr int + RelayPin int + SpeedHz int + PulseLength time.Duration +} + +// Open initializes Periph, opens the configured SPI device, and initializes the relay chip. +func Open(cfg Config) (*Relay, error) { + if _, err := host.Init(); err != nil { + return nil, fmt.Errorf("initialize periph host: %w", err) + } + + portName := fmt.Sprintf("/dev/spidev%d.%d", cfg.Bus, cfg.ChipSelect) + port, err := spireg.Open(portName) + if err != nil { + return nil, fmt.Errorf("open spi port %s: %w", portName, err) + } + + conn, err := port.Connect(physic.Frequency(cfg.SpeedHz)*physic.Hertz, spi.Mode0, 8) + if err != nil { + _ = port.Close() + return nil, fmt.Errorf("connect spi port %s: %w", portName, err) + } + + return newRelay(&periphConn{conn: conn, close: port.Close}, cfg.HardwareAddr, cfg.RelayPin, cfg.PulseLength) +} + +type periphConn struct { + conn spi.Conn + close func() error +} + +func (c *periphConn) Tx(w, r []byte) error { + return c.conn.Tx(w, r) +} + +func (c *periphConn) Close() error { + if c.close == nil { + return nil + } + return c.close() +} diff --git a/internal/dido/spi_relay.go b/internal/dido/spi_relay.go new file mode 100644 index 0000000..f2edf20 --- /dev/null +++ b/internal/dido/spi_relay.go @@ -0,0 +1,160 @@ +package dido + +import ( + "context" + "fmt" + "sync" + "time" +) + +const ( + registerIOCON = 0x0A + registerIODIRA = 0x00 + registerGPIOA = 0x12 + + bankOff = 0x00 + intMirrorOff = 0x00 + seqopOff = 0x20 + disslwOff = 0x00 + haenOn = 0x08 + odrOff = 0x00 + intpolLow = 0x00 +) + +type spiConn interface { + Tx(w, r []byte) error + Close() error +} + +// Relay controls a MCP23S17-backed DIDO relay output over SPI. +type Relay struct { + mu sync.Mutex + + conn spiConn + hardwareAddr int + relayPin int + pulseLength time.Duration + gpioaState byte +} + +func newRelay(conn spiConn, hardwareAddr int, relayPin int, pulseLength time.Duration) (*Relay, error) { + if conn == nil { + return nil, fmt.Errorf("spi connection is required") + } + if hardwareAddr < 0 || hardwareAddr > 7 { + return nil, fmt.Errorf("hardware address must be between 0 and 7") + } + if relayPin < 0 || relayPin > 7 { + return nil, fmt.Errorf("relay pin must be between 0 and 7") + } + if pulseLength <= 0 { + return nil, fmt.Errorf("pulse length must be > 0") + } + + r := &Relay{ + conn: conn, + hardwareAddr: hardwareAddr, + relayPin: relayPin, + pulseLength: pulseLength, + } + if err := r.initChip(); err != nil { + _ = conn.Close() + return nil, err + } + return r, nil +} + +// Pulse activates the configured relay briefly, then returns it to inactive. +func (r *Relay) Pulse(ctx context.Context) error { + r.mu.Lock() + if err := r.setRelayLocked(true); err != nil { + r.mu.Unlock() + return err + } + r.mu.Unlock() + + if err := sleepContext(ctx, r.pulseLength); err != nil { + _ = r.SetRelay(false) + return err + } + + if err := r.SetRelay(false); err != nil { + return err + } + return sleepContext(ctx, r.pulseLength) +} + +// SetRelay directly sets the relay output. It is used by Pulse and shutdown. +func (r *Relay) SetRelay(active bool) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.setRelayLocked(active) +} + +// Close turns the relay off and closes the SPI connection. +func (r *Relay) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + relayErr := r.setRelayLocked(false) + closeErr := r.conn.Close() + if relayErr != nil { + return relayErr + } + return closeErr +} + +func (r *Relay) initChip() error { + ioConfig := byte(bankOff | intMirrorOff | seqopOff | disslwOff | haenOn | odrOff | intpolLow) + if err := r.writeRegister(registerIOCON, ioConfig); err != nil { + return fmt.Errorf("write IOCON: %w", err) + } + if err := r.writeRegister(registerIODIRA, 0x00); err != nil { + return fmt.Errorf("write IODIRA: %w", err) + } + if err := r.writeRegister(registerGPIOA, 0x00); err != nil { + return fmt.Errorf("write GPIOA: %w", err) + } + return nil +} + +func (r *Relay) setRelayLocked(active bool) error { + bitMask := byte(1 << r.relayPin) + if active { + r.gpioaState |= bitMask + } else { + r.gpioaState &^= bitMask + } + return r.writeRegister(registerGPIOA, r.gpioaState) +} + +func (r *Relay) writeRegister(register byte, data byte) error { + frame := writeFrame(r.hardwareAddr, register, data) + if err := r.conn.Tx(frame, nil); err != nil { + return fmt.Errorf("spi tx % X: %w", frame, err) + } + return nil +} + +func writeFrame(hardwareAddr int, register byte, data byte) []byte { + return []byte{controlByte(hardwareAddr, true), register, data} +} + +func controlByte(hardwareAddr int, write bool) byte { + rwBit := byte(1) + if write { + rwBit = 0 + } + boardAddrPattern := byte((hardwareAddr << 1) & 0x0E) + return 0x40 | boardAddrPattern | rwBit +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/dido/spi_relay_test.go b/internal/dido/spi_relay_test.go new file mode 100644 index 0000000..73324cd --- /dev/null +++ b/internal/dido/spi_relay_test.go @@ -0,0 +1,172 @@ +package dido + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestControlByteMatchesPythonImplementation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hardwareAddr int + write bool + want byte + }{ + {name: "write addr 0", hardwareAddr: 0, write: true, want: 0x40}, + {name: "read addr 0", hardwareAddr: 0, write: false, want: 0x41}, + {name: "write addr 1", hardwareAddr: 1, write: true, want: 0x42}, + {name: "write addr 7", hardwareAddr: 7, write: true, want: 0x4E}, + {name: "read addr 7", hardwareAddr: 7, write: false, want: 0x4F}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := controlByte(tc.hardwareAddr, tc.write); got != tc.want { + t.Fatalf("controlByte=%#02x want=%#02x", got, tc.want) + } + }) + } +} + +func TestWriteFrame(t *testing.T) { + t.Parallel() + + got := writeFrame(2, registerGPIOA, 0x08) + want := []byte{0x44, 0x12, 0x08} + assertFrames(t, got, want) +} + +func TestNewRelayInitializesMCP23S17(t *testing.T) { + t.Parallel() + + conn := &fakeSPIConn{} + relay, err := newRelay(conn, 0, 0, time.Millisecond) + if err != nil { + t.Fatalf("newRelay: %v", err) + } + defer relay.Close() + + want := [][]byte{ + {0x40, registerIOCON, 0x28}, + {0x40, registerIODIRA, 0x00}, + {0x40, registerGPIOA, 0x00}, + } + assertFrameList(t, conn.frames, want) +} + +func TestPulseSetsAndClearsConfiguredRelayPin(t *testing.T) { + t.Parallel() + + conn := &fakeSPIConn{} + relay, err := newRelay(conn, 0, 3, time.Millisecond) + if err != nil { + t.Fatalf("newRelay: %v", err) + } + defer relay.Close() + + if err := relay.Pulse(context.Background()); err != nil { + t.Fatalf("pulse: %v", err) + } + + want := [][]byte{ + {0x40, registerIOCON, 0x28}, + {0x40, registerIODIRA, 0x00}, + {0x40, registerGPIOA, 0x00}, + {0x40, registerGPIOA, 0x08}, + {0x40, registerGPIOA, 0x00}, + } + assertFrameList(t, conn.frames[:5], want) +} + +func TestPulseClearsRelayWhenContextIsCanceled(t *testing.T) { + t.Parallel() + + conn := &fakeSPIConn{} + relay, err := newRelay(conn, 0, 1, time.Hour) + if err != nil { + t.Fatalf("newRelay: %v", err) + } + defer relay.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := relay.Pulse(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("pulse error=%v want context.Canceled", err) + } + + want := [][]byte{ + {0x40, registerIOCON, 0x28}, + {0x40, registerIODIRA, 0x00}, + {0x40, registerGPIOA, 0x00}, + {0x40, registerGPIOA, 0x02}, + {0x40, registerGPIOA, 0x00}, + } + assertFrameList(t, conn.frames[:5], want) +} + +func TestCloseTurnsRelayOff(t *testing.T) { + t.Parallel() + + conn := &fakeSPIConn{} + relay, err := newRelay(conn, 0, 2, time.Millisecond) + if err != nil { + t.Fatalf("newRelay: %v", err) + } + if err := relay.SetRelay(true); err != nil { + t.Fatalf("set relay: %v", err) + } + if err := relay.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if !conn.closed { + t.Fatalf("expected conn closed") + } + last := conn.frames[len(conn.frames)-1] + assertFrames(t, last, []byte{0x40, registerGPIOA, 0x00}) +} + +type fakeSPIConn struct { + frames [][]byte + closed bool +} + +func (f *fakeSPIConn) Tx(w, _ []byte) error { + frame := make([]byte, len(w)) + copy(frame, w) + f.frames = append(f.frames, frame) + return nil +} + +func (f *fakeSPIConn) Close() error { + f.closed = true + return nil +} + +func assertFrameList(t *testing.T, got [][]byte, want [][]byte) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("frame count=%d want=%d got=% X want=% X", len(got), len(want), got, want) + } + for i := range want { + assertFrames(t, got[i], want[i]) + } +} + +func assertFrames(t *testing.T, got []byte, want []byte) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("frame length=%d want=%d got=% X want=% X", len(got), len(want), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("frame=% X want=% X", got, want) + } + } +} diff --git a/internal/domain/ports.go b/internal/domain/ports.go new file mode 100644 index 0000000..da0fd8d --- /dev/null +++ b/internal/domain/ports.go @@ -0,0 +1,23 @@ +package domain + +import "context" + +// GateHardware abstracts the DIDO board used to pulse the FAAC IN 1 input. +type GateHardware interface { + Pulse(ctx context.Context) error + Close() error +} + +// CommandSubscriber abstracts inbound control commands. +type CommandSubscriber interface { + SubscribeCommands(ctx context.Context, handler func(Command) error) error + Close() error +} + +// StatePublisher abstracts outbound MQTT state publication. +type StatePublisher interface { + PublishState(ctx context.Context, state GateState) error + PublishTargetState(ctx context.Context, target Command) error + PublishAvailability(ctx context.Context, online bool) error + Close() error +} diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..4de3dec --- /dev/null +++ b/internal/domain/types.go @@ -0,0 +1,35 @@ +package domain + +import "strings" + +// GateState is the normalized state exposed to MQTT and the rest of the app. +type GateState string + +const ( + StateUnknown GateState = "UNKNOWN" + StateOpen GateState = "OPEN" + StateClosed GateState = "CLOSED" + StateOpening GateState = "OPENING" + StateClosing GateState = "CLOSING" + StateStopped GateState = "STOPPED" +) + +// Command is the normalized control command accepted from MQTT. +type Command string + +const ( + CommandOpen Command = "open" + CommandClose Command = "close" +) + +// NormalizeCommand validates and normalizes user input into a supported command. +func NormalizeCommand(v string) (Command, bool) { + switch strings.ToLower(strings.TrimSpace(v)) { + case string(CommandOpen): + return CommandOpen, true + case string(CommandClose): + return CommandClose, true + default: + return "", false + } +} diff --git a/internal/domain/types_test.go b/internal/domain/types_test.go new file mode 100644 index 0000000..3958d54 --- /dev/null +++ b/internal/domain/types_test.go @@ -0,0 +1,34 @@ +package domain + +import "testing" + +func TestNormalizeCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want Command + ok bool + }{ + {input: "open", want: CommandOpen, ok: true}, + {input: " OPEN ", want: CommandOpen, ok: true}, + {input: "close", want: CommandClose, ok: true}, + {input: "CLOSE", want: CommandClose, ok: true}, + {input: "stop", ok: false}, + {input: "", ok: false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + got, ok := NormalizeCommand(tc.input) + if ok != tc.ok { + t.Fatalf("ok mismatch: got=%v want=%v", ok, tc.ok) + } + if got != tc.want { + t.Fatalf("command mismatch: got=%q want=%q", got, tc.want) + } + }) + } +} diff --git a/internal/mocks/dido.go b/internal/mocks/dido.go new file mode 100644 index 0000000..b3be79c --- /dev/null +++ b/internal/mocks/dido.go @@ -0,0 +1,97 @@ +package mocks + +import ( + "context" + "fmt" + "sync" + "time" +) + +// MockDIDO mimics the observable relay behavior of the DIDO board. +type MockDIDO struct { + mu sync.Mutex + + pulseLength time.Duration + transitions []bool + pulses int + closed bool + failPulse bool +} + +func NewMockDIDO(pulseLength time.Duration) *MockDIDO { + if pulseLength <= 0 { + pulseLength = 500 * time.Millisecond + } + return &MockDIDO{pulseLength: pulseLength} +} + +func (m *MockDIDO) Pulse(ctx context.Context) error { + m.mu.Lock() + if m.failPulse { + m.mu.Unlock() + return fmt.Errorf("mock pulse failure") + } + m.transitions = append(m.transitions, true) + m.pulses++ + m.mu.Unlock() + + if err := sleepContext(ctx, m.pulseLength); err != nil { + m.mu.Lock() + m.transitions = append(m.transitions, false) + m.mu.Unlock() + return err + } + + m.mu.Lock() + m.transitions = append(m.transitions, false) + m.mu.Unlock() + + return sleepContext(ctx, m.pulseLength) +} + +func (m *MockDIDO) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.transitions) == 0 || m.transitions[len(m.transitions)-1] { + m.transitions = append(m.transitions, false) + } + m.closed = true + return nil +} + +func (m *MockDIDO) SetFailPulse(fail bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.failPulse = fail +} + +func (m *MockDIDO) Pulses() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.pulses +} + +func (m *MockDIDO) Transitions() []bool { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]bool, len(m.transitions)) + copy(out, m.transitions) + return out +} + +func (m *MockDIDO) Closed() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.closed +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/mocks/mqtt.go b/internal/mocks/mqtt.go new file mode 100644 index 0000000..ac024b5 --- /dev/null +++ b/internal/mocks/mqtt.go @@ -0,0 +1,104 @@ +package mocks + +import ( + "context" + "sync" + + "gatecontrol/internal/domain" +) + +// MockMQTT implements command input and state output in memory. +type MockMQTT struct { + mu sync.Mutex + + handler func(domain.Command) error + states []domain.GateState + targets []domain.Command + online []bool + closed bool +} + +func NewMockMQTT() *MockMQTT { + return &MockMQTT{} +} + +func (m *MockMQTT) SubscribeCommands(_ context.Context, handler func(domain.Command) error) error { + m.mu.Lock() + defer m.mu.Unlock() + m.handler = handler + return nil +} + +func (m *MockMQTT) PublishState(_ context.Context, state domain.GateState) error { + m.mu.Lock() + defer m.mu.Unlock() + m.states = append(m.states, state) + return nil +} + +func (m *MockMQTT) PublishTargetState(_ context.Context, target domain.Command) error { + m.mu.Lock() + defer m.mu.Unlock() + m.targets = append(m.targets, target) + return nil +} + +func (m *MockMQTT) PublishAvailability(_ context.Context, online bool) error { + m.mu.Lock() + defer m.mu.Unlock() + m.online = append(m.online, online) + return nil +} + +func (m *MockMQTT) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + return nil +} + +func (m *MockMQTT) Inject(command domain.Command) error { + m.mu.Lock() + h := m.handler + m.mu.Unlock() + if h == nil { + return nil + } + return h(command) +} + +func (m *MockMQTT) HandlerReady() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.handler != nil +} + +func (m *MockMQTT) States() []domain.GateState { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]domain.GateState, len(m.states)) + copy(out, m.states) + return out +} + +func (m *MockMQTT) Targets() []domain.Command { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]domain.Command, len(m.targets)) + copy(out, m.targets) + return out +} + +func (m *MockMQTT) Availability() []bool { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]bool, len(m.online)) + copy(out, m.online) + return out +} + +func (m *MockMQTT) Closed() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.closed +} diff --git a/internal/mqtt/client.go b/internal/mqtt/client.go new file mode 100644 index 0000000..97ba8c6 --- /dev/null +++ b/internal/mqtt/client.go @@ -0,0 +1,209 @@ +package mqtt + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "gatecontrol/internal/domain" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +// Client is the concrete MQTT adapter implementing: +// - domain.CommandSubscriber +// - domain.StatePublisher +// +// It receives commands on `/command` and publishes: +// - `/state` +// - `/target_state` +// - `/availability` +type Client struct { + baseTopic string + command string + state string + target string + avail string + + logger *slog.Logger + client paho.Client + + handlerMu sync.RWMutex + handler func(domain.Command) error +} + +// Config contains connection and topic settings for MQTT. +type Config struct { + // BrokerURL example: tcp://localhost:1883 + BrokerURL string + // ClientID should be unique per running gateway process. + ClientID string + // Username/Password are optional for authenticated brokers. + Username string + Password string + // BaseTopic defaults to "faac/gate" when empty. + BaseTopic string +} + +// New creates an MQTT adapter but does not connect yet. +func New(cfg Config, logger *slog.Logger) (*Client, error) { + if logger == nil { + logger = slog.Default() + } + if strings.TrimSpace(cfg.BrokerURL) == "" { + return nil, fmt.Errorf("broker URL is required") + } + base := strings.Trim(strings.TrimSpace(cfg.BaseTopic), "/") + if base == "" { + base = "faac/gate" + } + + c := &Client{ + baseTopic: base, + command: fmt.Sprintf("%s/command", base), + state: fmt.Sprintf("%s/state", base), + target: fmt.Sprintf("%s/target_state", base), + avail: fmt.Sprintf("%s/availability", base), + logger: logger, + } + + opts := paho.NewClientOptions() + opts.AddBroker(cfg.BrokerURL) + opts.SetClientID(cfg.ClientID) + opts.SetAutoReconnect(true) + opts.SetConnectRetry(true) + opts.SetConnectRetryInterval(2 * time.Second) + opts.SetOrderMatters(false) + opts.SetCleanSession(false) + opts.SetWill(c.avail, "offline", 1, true) + + if cfg.Username != "" { + opts.SetUsername(cfg.Username) + opts.SetPassword(cfg.Password) + } + + opts.OnConnect = func(cl paho.Client) { + if token := cl.Subscribe(c.command, 1, c.onMessage); token.Wait() && token.Error() != nil { + c.logger.Error("mqtt subscribe failed", "topic", c.command, "err", token.Error()) + return + } + c.logger.Info("mqtt connected", "command_topic", c.command) + } + + opts.OnConnectionLost = func(_ paho.Client, err error) { + c.logger.Warn("mqtt connection lost", "err", err) + } + + c.client = paho.NewClient(opts) + return c, nil +} + +// Connect establishes a broker connection within the context deadline. +func (c *Client) Connect(ctx context.Context) error { + token := c.client.Connect() + if !token.WaitTimeout(waitFromContext(ctx, 10*time.Second)) { + return fmt.Errorf("mqtt connect timeout") + } + if err := token.Error(); err != nil { + return fmt.Errorf("mqtt connect: %w", err) + } + return nil +} + +// SubscribeCommands stores the callback used by incoming MQTT messages. +// +// Actual MQTT topic subscription is configured in OnConnect so reconnects +// automatically re-subscribe. +func (c *Client) SubscribeCommands(_ context.Context, handler func(domain.Command) error) error { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.handler = handler + return nil +} + +// PublishState publishes retained state (OPEN/CLOSED/OPENING/CLOSING/STOPPED). +func (c *Client) PublishState(ctx context.Context, state domain.GateState) error { + token := c.client.Publish(c.state, 1, true, string(state)) + if !token.WaitTimeout(waitFromContext(ctx, 5*time.Second)) { + return fmt.Errorf("mqtt publish state timeout") + } + if err := token.Error(); err != nil { + return fmt.Errorf("mqtt publish state: %w", err) + } + return nil +} + +// PublishTargetState publishes retained target command ("open" or "close"). +func (c *Client) PublishTargetState(ctx context.Context, target domain.Command) error { + token := c.client.Publish(c.target, 1, true, string(target)) + if !token.WaitTimeout(waitFromContext(ctx, 5*time.Second)) { + return fmt.Errorf("mqtt publish target_state timeout") + } + if err := token.Error(); err != nil { + return fmt.Errorf("mqtt publish target_state: %w", err) + } + return nil +} + +// PublishAvailability publishes retained availability ("online"/"offline"). +func (c *Client) PublishAvailability(ctx context.Context, online bool) error { + payload := "offline" + if online { + payload = "online" + } + token := c.client.Publish(c.avail, 1, true, payload) + if !token.WaitTimeout(waitFromContext(ctx, 5*time.Second)) { + return fmt.Errorf("mqtt publish availability timeout") + } + if err := token.Error(); err != nil { + return fmt.Errorf("mqtt publish availability: %w", err) + } + return nil +} + +// Close disconnects from broker and flushes inflight messages briefly. +func (c *Client) Close() error { + if c.client != nil && c.client.IsConnectionOpen() { + c.client.Disconnect(250) + } + return nil +} + +// onMessage handles command topic payloads. +// +// Invalid command strings are ignored with a warning. +func (c *Client) onMessage(_ paho.Client, m paho.Message) { + cmd, ok := domain.NormalizeCommand(string(m.Payload())) + if !ok { + c.logger.Warn("ignored invalid mqtt command", "payload", string(m.Payload())) + return + } + + c.handlerMu.RLock() + h := c.handler + c.handlerMu.RUnlock() + if h == nil { + return + } + if err := h(cmd); err != nil { + c.logger.Warn("command handler error", "cmd", cmd, "err", err) + } +} + +// waitFromContext derives a timeout for MQTT token waits. +// +// If the context has a deadline, that remaining duration is used. +// Otherwise fallback is returned. +func waitFromContext(ctx context.Context, fallback time.Duration) time.Duration { + if dl, ok := ctx.Deadline(); ok { + remaining := time.Until(dl) + if remaining <= 0 { + return 0 + } + return remaining + } + return fallback +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 28d53b7..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -fastapi==0.135.1 -uvicorn==0.42.0 -httpx==0.28.1 -requests==2.32.5 -aiounittest==1.5.0 -spidev==3.8 -psutil==7.2.2