From d324a53c16bbb17da7bf5e9d216c419a5932c28c Mon Sep 17 00:00:00 2001 From: Kamil Jan Mularski Date: Sat, 30 May 2026 12:53:03 +0200 Subject: [PATCH 1/2] Add configurable GPIO settings --- README.MD => README.md | 84 +++++++++++++++++++++++++++++++++++++++++- gpio.json | 1 + myhttp.py | 42 ++++++++++++++++++++- utils.py | 25 ++++++++++++- waterflowdriver.py | 31 ++++++++++++++-- 5 files changed, 176 insertions(+), 7 deletions(-) rename README.MD => README.md (82%) create mode 100644 gpio.json diff --git a/README.MD b/README.md similarity index 82% rename from README.MD rename to README.md index 15f94b7..ffa92ac 100644 --- a/README.MD +++ b/README.md @@ -17,7 +17,7 @@ This project is intended to be installed on: - **Raspberry Pi Pico 2 W** - together with a **dedicated expansion board** (I/O and power handling for LED strip / actuators). -The code uses specific GPIO pins (including 18, 19, 20), so hardware compatibility with the target expansion module is assumed. +Default GPIO pins are `sensor=18`, `pixel=19`, and `off=20`. They can be changed in `gpio.json` or through the administrator-only `api/secure/gpio` endpoint, so hardware compatibility with the target expansion module should be verified after every GPIO change. --- @@ -57,11 +57,31 @@ A static file with device metadata and security settings: - `users.json` – users, passwords, tokens - `groups.json` – role mapping (`admin`, `designer`, `editor`) - `data.json` – device runtime configuration (e.g. `pixelProgram`, `brightness`, `stepTime`, `onTime`, `offTime`, `on`, `nol`, ...) +- `gpio.json` – GPIO port numbers used by the device (`pixel`, `sensor`, `off`) - `pixelprograms.json` – list of pixel animation programs - log file (`logging.log_file`, depending on `phew.logging` setup) > On first startup, the backend ensures a default RBAC structure (roles + `admin` account). + +### 3.3 `gpio.json` +GPIO configuration is loaded from `gpio.json` at startup and refreshed by the runtime driver. Default content: + +```json +{ + "pixel": 19, + "sensor": 18, + "off": 20 +} +``` + +Fields: +- `pixel` – GPIO number for the LED strip data line, +- `sensor` – GPIO number for the input sensor (`Pin.IN`, `Pin.PULL_UP`), +- `off` – GPIO number for the output used when the controller is inactive. + +Accepted values are integers from `0` to `28`. Invalid or missing values are replaced by defaults when the file is normalized. + --- ## 4. API authentication and authorization @@ -283,6 +303,55 @@ Returns `secured` section from `config.py`. --- + +### `GET /api/secure/gpio` +Returns current GPIO configuration from `gpio.json`. + +- Required role: `admin` + +**200 OK** +```json +{ + "gpio": {"pixel":19,"sensor":18,"off":20}, + "newCredentials": {"user":"admin","token":"..."} +} +``` + +--- + +### `PUT /api/secure/gpio` +Replaces full GPIO configuration. + +### `PATCH /api/secure/gpio` +Partially updates GPIO configuration. + +- Required role: `admin` +- Accepted fields: `pixel`, `sensor`, `off` +- Accepted values: integers from `0` to `28` +- The backend saves normalized values to `gpio.json` and applies changed pins to the running driver. + +**Body (JSON)** +```json +{ + "pixel": 19, + "sensor": 18, + "off": 20 +} +``` + +**202 Accepted** +```json +{ + "before": {"pixel":19,"sensor":18,"off":20}, + "after": {"pixel":21,"sensor":18,"off":20}, + "newCredentials": {"user":"admin","token":"..."} +} +``` + +**Errors:** `400`, `401`, `403`, `500`. + +--- + ### `GET /api/secure/users` Returns mapping of users to roles. @@ -413,6 +482,16 @@ curl -X PATCH http:///api/data \ -d '{"brightness":128,"on":true}' ``` + +### GPIO configuration update (PATCH /api/secure/gpio) +```bash +curl -X PATCH http:///api/secure/gpio \ + -H "Content-Type: application/json" \ + -H "user: admin" \ + -H "token: " \ + -d '{"pixel":21}' +``` + ### Admin password reset with `secure` ```bash curl -X POST http:///api/secure/admin/reset \ @@ -438,13 +517,14 @@ A practical and safe deployment path: - Copy to device storage: - `main.py`, `myhttp.py`, `waterflowdriver.py`, `waterflowpixel.py`, `auth.py`, `utils.py`, `config.py`, `ktime.py`, `neopixel.py` - `lib/` folder - - required JSON files (`net.json`, `users.json`, `groups.json`, `data.json`, `pixelprograms.json`) configured for your environment. + - required JSON files (`net.json`, `users.json`, `groups.json`, `data.json`, `gpio.json`, `pixelprograms.json`) configured for your environment. 3. **Adjust configuration before first run** - Set unique values in `config.py`, especially: - `secured['secure']` - `device['uuid']` - Set Wi‑Fi/AP credentials in `net.json`. + - Verify GPIO numbers in `gpio.json` for your expansion board. 4. **Restart and test API** - Restart device (soft/hard reset). diff --git a/gpio.json b/gpio.json new file mode 100644 index 0000000..87e478a --- /dev/null +++ b/gpio.json @@ -0,0 +1 @@ +{"pixel": 19, "sensor": 18, "off": 20} diff --git a/myhttp.py b/myhttp.py index 04a055c..9e70983 100644 --- a/myhttp.py +++ b/myhttp.py @@ -7,7 +7,7 @@ from waterflowdriver import WaterflowDriver import ubinascii import auth -from utils import load_json, save_json +from utils import load_json, save_json, load_gpio_config net = load_json('net.json') @@ -167,6 +167,46 @@ def get_secure(request): newCredentials = auth.refresh_token(user) return json.dumps({'secure': config.secured, 'newCredentials': newCredentials}), 200, {"Content-type": "application/json"} + +@server.route("/api/secure/gpio", methods=["GET"]) +def get_gpio(request): + user = auth.authenticate(request) + if not user: + return json.dumps({"message": "Unauthorized"}), 401, {"Content-type": "application/json"} + if not auth.authorize(user, ["admin"]): + return json.dumps({"message": "Forbidden"}), 403, {"Content-type": "application/json"} + newCredentials = auth.refresh_token(user) + return json.dumps({'gpio': load_gpio_config(), 'newCredentials': newCredentials}), 200, {"Content-type": "application/json"} + +@server.route("/api/secure/gpio", methods=["PUT", "PATCH"]) +def change_gpio(request): + user = auth.authenticate(request) + if not user: + return json.dumps({"message": "Unauthorized"}), 401, {"Content-type": "application/json"} + if not auth.authorize(user, ["admin"]): + return json.dumps({"message": "Forbidden"}), 403, {"Content-type": "application/json"} + + allowed_keys = ['pixel', 'sensor', 'off'] + data = request.data + for key in data: + if key not in allowed_keys: + return json.dumps({"message": "Bad Request"}), 400, {"Content-type": "application/json"} + value = data[key] + if not isinstance(value, int) or value < 0 or value > 28: + return json.dumps({"message": "Bad Request"}), 400, {"Content-type": "application/json"} + + old = load_gpio_config() + new = data + if request.method == "PATCH": + new = old.copy() + new.update(data) + + if driver.configure_gpio(new): + newCredentials = auth.refresh_token(user) + return json.dumps({'before': old, 'after': load_gpio_config(), 'newCredentials': newCredentials}), 202, {"Content-type": "application/json"} + else: + return json.dumps({'message': 'Internal Server Error'}), 500, {"Content-type": "application/json"} + @server.route("/api/secure/users", methods=["GET"]) def list_users_groups(request): user = auth.authenticate(request) diff --git a/utils.py b/utils.py index 8fd977d..8693b6c 100644 --- a/utils.py +++ b/utils.py @@ -16,4 +16,27 @@ def save_json(filename, content): return True except: logging.debug(f'Cannot write {content} into {filename}') - return False \ No newline at end of file + return False + +DEFAULT_GPIO = { + 'pixel': 19, + 'sensor': 18, + 'off': 20 +} + +def normalize_gpio_config(data): + gpio = DEFAULT_GPIO.copy() + if data is None: + data = {} + for key in gpio: + if key in data: + value = data[key] + if isinstance(value, int) and 0 <= value <= 28: + gpio[key] = value + return gpio + +def load_gpio_config(): + return normalize_gpio_config(load_json('gpio.json')) + +def save_gpio_config(data): + return save_json('gpio.json', normalize_gpio_config(data)) diff --git a/waterflowdriver.py b/waterflowdriver.py index 8c3c2e9..b82dc66 100644 --- a/waterflowdriver.py +++ b/waterflowdriver.py @@ -2,6 +2,7 @@ from phew import logging from machine import Pin from ktime import LocalTime +from utils import load_gpio_config, save_gpio_config import time, _thread import ujson as json import uasyncio @@ -9,7 +10,8 @@ class WaterflowDriver: def __init__(self): - self.offPin = Pin(20, Pin.OUT, value=0) + self.gpio = load_gpio_config() + self.offPin = Pin(self.gpio['off'], Pin.OUT, value=0) self.restartCountdown = -1 self.time = LocalTime() self.time.timeZoneRTCCorrection() @@ -17,8 +19,8 @@ def __init__(self): self.offTime = 28800 self.timeDependency = True self.nol = 3 - self.waterflow = WaterflowPixel(self.nol, 0, 19) - self.sensorPin = Pin(18, Pin.IN, Pin.PULL_UP) + self.waterflow = WaterflowPixel(self.nol, 0, self.gpio['pixel']) + self.sensorPin = Pin(self.gpio['sensor'], Pin.IN, Pin.PULL_UP) self.brightness = 255 self.pixels = [] self.alive = False @@ -37,6 +39,28 @@ def __repr__(self): def setOperatingTime(self, start: LocalTime, end: LocalTime): self.onTime = start.timestamp() self.offTime = end.timestamp() + + def configure_gpio(self, gpio): + previous = self.gpio.copy() + if save_gpio_config(gpio): + self.gpio = load_gpio_config() + if previous.get('off') != self.gpio['off']: + self.offPin.value(0) + self.offPin = Pin(self.gpio['off'], Pin.OUT, value=0) + if previous.get('sensor') != self.gpio['sensor']: + self.sensorPin = Pin(self.gpio['sensor'], Pin.IN, Pin.PULL_UP) + if previous.get('pixel') != self.gpio['pixel']: + self.waterflow.removeAll() + self.waterflow = WaterflowPixel(self.waterflow.strip.num_leds, 0, self.gpio['pixel']) + logging.info(f"> GPIO configuration changed from {previous} to {self.gpio}") + return True + return False + + def reload_gpio(self): + gpio = load_gpio_config() + if gpio != self.gpio: + return self.configure_gpio(gpio) + return True def current_state(self): state = self.on @@ -87,6 +111,7 @@ async def prepareStep(self): if (len(self.pixels) == 0): lock = uasyncio.Lock() async with lock: + self.reload_gpio() try: with open('data.json', 'r') as f: self.data = json.load(f) From 447cabf7a068f8809ec0e5e476b82ed8c64f7f3e Mon Sep 17 00:00:00 2001 From: Kamil Jan Mularski Date: Sat, 30 May 2026 14:53:57 +0200 Subject: [PATCH 2/2] Address GPIO endpoint review comments --- README.md | 27 +++++++++++---------------- gpio.json | 2 +- myhttp.py | 10 ++-------- utils.py | 2 +- waterflowdriver.py | 6 +++--- 5 files changed, 18 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ffa92ac..74c20ab 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This project is intended to be installed on: - **Raspberry Pi Pico 2 W** - together with a **dedicated expansion board** (I/O and power handling for LED strip / actuators). -Default GPIO pins are `sensor=18`, `pixel=19`, and `off=20`. They can be changed in `gpio.json` or through the administrator-only `api/secure/gpio` endpoint, so hardware compatibility with the target expansion module should be verified after every GPIO change. +Default GPIO pins are `sensor=18`, `dout=19`, and `off=20`. They can be changed in `gpio.json` or through the `api/secure/gpio` endpoint. Reading this endpoint is public; changing values requires an administrator. Hardware compatibility with the target expansion module should be verified after every GPIO change. --- @@ -57,7 +57,7 @@ A static file with device metadata and security settings: - `users.json` – users, passwords, tokens - `groups.json` – role mapping (`admin`, `designer`, `editor`) - `data.json` – device runtime configuration (e.g. `pixelProgram`, `brightness`, `stepTime`, `onTime`, `offTime`, `on`, `nol`, ...) -- `gpio.json` – GPIO port numbers used by the device (`pixel`, `sensor`, `off`) +- `gpio.json` – GPIO port numbers used by the device (`dout`, `sensor`, `off`) - `pixelprograms.json` – list of pixel animation programs - log file (`logging.log_file`, depending on `phew.logging` setup) @@ -69,14 +69,14 @@ GPIO configuration is loaded from `gpio.json` at startup and refreshed by the ru ```json { - "pixel": 19, + "dout": 19, "sensor": 18, "off": 20 } ``` Fields: -- `pixel` – GPIO number for the LED strip data line, +- `dout` – GPIO number for the LED strip data line, - `sensor` – GPIO number for the input sensor (`Pin.IN`, `Pin.PULL_UP`), - `off` – GPIO number for the output used when the controller is inactive. @@ -305,16 +305,11 @@ Returns `secured` section from `config.py`. ### `GET /api/secure/gpio` -Returns current GPIO configuration from `gpio.json`. - -- Required role: `admin` +Returns current GPIO configuration from `gpio.json`. This read endpoint is public and does not refresh or return an access token. **200 OK** ```json -{ - "gpio": {"pixel":19,"sensor":18,"off":20}, - "newCredentials": {"user":"admin","token":"..."} -} +{"dout":19,"sensor":18,"off":20} ``` --- @@ -326,14 +321,14 @@ Replaces full GPIO configuration. Partially updates GPIO configuration. - Required role: `admin` -- Accepted fields: `pixel`, `sensor`, `off` +- Accepted fields: `dout`, `sensor`, `off` - Accepted values: integers from `0` to `28` - The backend saves normalized values to `gpio.json` and applies changed pins to the running driver. **Body (JSON)** ```json { - "pixel": 19, + "dout": 19, "sensor": 18, "off": 20 } @@ -342,8 +337,8 @@ Partially updates GPIO configuration. **202 Accepted** ```json { - "before": {"pixel":19,"sensor":18,"off":20}, - "after": {"pixel":21,"sensor":18,"off":20}, + "before": {"dout":19,"sensor":18,"off":20}, + "after": {"dout":21,"sensor":18,"off":20}, "newCredentials": {"user":"admin","token":"..."} } ``` @@ -489,7 +484,7 @@ curl -X PATCH http:///api/secure/gpio \ -H "Content-Type: application/json" \ -H "user: admin" \ -H "token: " \ - -d '{"pixel":21}' + -d '{"dout":21}' ``` ### Admin password reset with `secure` diff --git a/gpio.json b/gpio.json index 87e478a..1facb29 100644 --- a/gpio.json +++ b/gpio.json @@ -1 +1 @@ -{"pixel": 19, "sensor": 18, "off": 20} +{"dout": 19, "sensor": 18, "off": 20} diff --git a/myhttp.py b/myhttp.py index 9e70983..004d5da 100644 --- a/myhttp.py +++ b/myhttp.py @@ -170,13 +170,7 @@ def get_secure(request): @server.route("/api/secure/gpio", methods=["GET"]) def get_gpio(request): - user = auth.authenticate(request) - if not user: - return json.dumps({"message": "Unauthorized"}), 401, {"Content-type": "application/json"} - if not auth.authorize(user, ["admin"]): - return json.dumps({"message": "Forbidden"}), 403, {"Content-type": "application/json"} - newCredentials = auth.refresh_token(user) - return json.dumps({'gpio': load_gpio_config(), 'newCredentials': newCredentials}), 200, {"Content-type": "application/json"} + return json.dumps(load_gpio_config()), 200, {"Content-type": "application/json"} @server.route("/api/secure/gpio", methods=["PUT", "PATCH"]) def change_gpio(request): @@ -186,7 +180,7 @@ def change_gpio(request): if not auth.authorize(user, ["admin"]): return json.dumps({"message": "Forbidden"}), 403, {"Content-type": "application/json"} - allowed_keys = ['pixel', 'sensor', 'off'] + allowed_keys = ['dout', 'sensor', 'off'] data = request.data for key in data: if key not in allowed_keys: diff --git a/utils.py b/utils.py index 8693b6c..3477e0c 100644 --- a/utils.py +++ b/utils.py @@ -19,7 +19,7 @@ def save_json(filename, content): return False DEFAULT_GPIO = { - 'pixel': 19, + 'dout': 19, 'sensor': 18, 'off': 20 } diff --git a/waterflowdriver.py b/waterflowdriver.py index b82dc66..dc07be7 100644 --- a/waterflowdriver.py +++ b/waterflowdriver.py @@ -19,7 +19,7 @@ def __init__(self): self.offTime = 28800 self.timeDependency = True self.nol = 3 - self.waterflow = WaterflowPixel(self.nol, 0, self.gpio['pixel']) + self.waterflow = WaterflowPixel(self.nol, 0, self.gpio['dout']) self.sensorPin = Pin(self.gpio['sensor'], Pin.IN, Pin.PULL_UP) self.brightness = 255 self.pixels = [] @@ -49,9 +49,9 @@ def configure_gpio(self, gpio): self.offPin = Pin(self.gpio['off'], Pin.OUT, value=0) if previous.get('sensor') != self.gpio['sensor']: self.sensorPin = Pin(self.gpio['sensor'], Pin.IN, Pin.PULL_UP) - if previous.get('pixel') != self.gpio['pixel']: + if previous.get('dout') != self.gpio['dout']: self.waterflow.removeAll() - self.waterflow = WaterflowPixel(self.waterflow.strip.num_leds, 0, self.gpio['pixel']) + self.waterflow = WaterflowPixel(self.waterflow.strip.num_leds, 0, self.gpio['dout']) logging.info(f"> GPIO configuration changed from {previous} to {self.gpio}") return True return False