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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions README.MD → README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `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.

---

Expand Down Expand Up @@ -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 (`dout`, `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
{
"dout": 19,
"sensor": 18,
"off": 20
}
```

Fields:
- `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.

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
Expand Down Expand Up @@ -283,6 +303,50 @@ Returns `secured` section from `config.py`.

---


### `GET /api/secure/gpio`
Returns current GPIO configuration from `gpio.json`. This read endpoint is public and does not refresh or return an access token.

**200 OK**
```json
{"dout":19,"sensor":18,"off":20}
```

---

### `PUT /api/secure/gpio`
Replaces full GPIO configuration.

### `PATCH /api/secure/gpio`
Partially updates GPIO configuration.

- Required role: `admin`
- 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
{
"dout": 19,
"sensor": 18,
"off": 20
}
```

**202 Accepted**
```json
{
"before": {"dout":19,"sensor":18,"off":20},
"after": {"dout":21,"sensor":18,"off":20},
"newCredentials": {"user":"admin","token":"..."}
}
```

**Errors:** `400`, `401`, `403`, `500`.

---

### `GET /api/secure/users`
Returns mapping of users to roles.

Expand Down Expand Up @@ -413,6 +477,16 @@ curl -X PATCH http://<DEVICE_IP>/api/data \
-d '{"brightness":128,"on":true}'
```


### GPIO configuration update (PATCH /api/secure/gpio)
```bash
curl -X PATCH http://<DEVICE_IP>/api/secure/gpio \
-H "Content-Type: application/json" \
-H "user: admin" \
-H "token: <TOKEN>" \
-d '{"dout":21}'
```

### Admin password reset with `secure`
```bash
curl -X POST http://<DEVICE_IP>/api/secure/admin/reset \
Expand All @@ -438,13 +512,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).
Expand Down
1 change: 1 addition & 0 deletions gpio.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"dout": 19, "sensor": 18, "off": 20}
36 changes: 35 additions & 1 deletion myhttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -167,6 +167,40 @@ 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):
return json.dumps(load_gpio_config()), 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 = ['dout', '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"}
Comment on lines +189 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject boolean GPIO values before applying them

For JSON bodies containing booleans, this validation accepts them because bool is an int subclass in Python/MicroPython, so a request like {"pixel": true} passes the documented integer check and is then persisted/applied as GPIO 1. That makes malformed API input silently remap hardware pins; reject bool explicitly before calling configure_gpio().

Useful? React with 👍 / 👎.


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)
Expand Down
25 changes: 24 additions & 1 deletion utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,27 @@ def save_json(filename, content):
return True
except:
logging.debug(f'Cannot write {content} into {filename}')
return False
return False

DEFAULT_GPIO = {
'dout': 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))
31 changes: 28 additions & 3 deletions waterflowdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@
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

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()
self.onTime = 72000
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['dout'])
self.sensorPin = Pin(self.gpio['sensor'], Pin.IN, Pin.PULL_UP)
self.brightness = 255
self.pixels = []
self.alive = False
Expand All @@ -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('dout') != self.gpio['dout']:
self.waterflow.removeAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush the old LED strip before switching pins

When an admin changes pixel while LEDs are lit, this only clears the old WaterflowPixel object's in-memory buffer. WaterflowPixel.removeAll() does not call show(), and the next line replaces the object with one bound to the new GPIO, so the old data line never receives zeros and any lit LEDs can remain latched on until that old pin is driven again. Flush the cleared buffer on the old instance before constructing the replacement.

Useful? React with 👍 / 👎.

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

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
Expand Down Expand Up @@ -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)
Expand Down