
WaterPlant Pi
airiclenz/tethys
Battery-powered wireless soil-moisture sensors report to a Raspberry Pi master over a 2.4 GHz nRF24 radio link. The master decides when to water, drives the pumps/valves, and serves a real-time web dashboard - all on…

Battery-powered wireless soil-moisture sensors report to a Raspberry Pi master over a 2.4 GHz nRF24 radio link. The master decides when to water, drives the pumps/valves, and serves a real-time web dashboard - all on your own LAN, no cloud.
Sensor is an open-source printed circuit board by airiclenz, from the airiclenz/tethys repository on GitHub, published under the MIT licence. It is a 2-layer board of 39.3 × 145.9 mm with 35 parts on 20 nets, routed with 188 tracks and 39 vias.
It is built around the ATTINY84-20SSU, NRF24L01 and JK-SMD0603-005. Its parts include 8 capacitors, 6 resistors, 4 diodes, 4 connectors and 3 ICs. It belongs with the sensors and measurement and audio and music boards and the AVR (ATmega, ATtiny) boards of this library.
A self-hosted, multi-channel automatic plant-watering system.
Battery-powered wireless soil-moisture sensors report to a Raspberry Pi master over a 2.4 GHz nRF24 radio link. The master decides when to water, drives the pumps/valves, and serves a real-time web dashboard — all on your own LAN, no cloud.
Tethys has two kinds of hardware:
Sensor nodes (one per plant/pot, up to 5) — a battery-powered ATtiny84 board with a capacitive soil-moisture probe and an nRF24L01+ radio. Each node sleeps almost all the time, wakes on a configurable interval, measures soil moisture and battery voltage, transmits a reading, and goes back to sleep. Designed to run for months on a single charge (~9 µA while sleeping).
The master — a Raspberry Pi with a custom HAT carrying the matching nRF24L01+ radio plus pump/valve driver outputs. The master listens for sensor readings, stores them, and when a channel's soil drops below its trigger threshold it runs that channel's pump (or opens its valve) for a configured duration. It also serves the web UI you use to configure and monitor everything.
┌─────────────┐ nRF24 2.4GHz ┌──────────────────────────────────┐
│ Sensor node │ (250kbps, 8-byte │ Raspberry Pi master │
│ ATtiny84 │ versioned frames) │ │
│ + moisture │ ◄──── reading ────► │ core daemon ─► pump/valve GPIO │
│ + nRF24 │ config ◄──── │ │ │
│ (battery) │ │ Django API (SQLite) ◄─► web UI │
└─────────────┘ └──────────────┬───────────────────┘
× up to 5 │ HTTP / WebSocket
▼
Your browser / phoneEach channel (1..5) pairs one sensor node with one pump or valve output. A channel knows its soil-moisture trigger %, its pump run duration, and how often its sensor should measure.
tethys/
├── code/
│ ├── master/ # Everything that runs on the Raspberry Pi
│ │ ├── core/ # The watering daemon (radio + pump control + action engine)
│ │ ├── api/ # Django REST API (the system's database + state)
│ │ ├── web/ # Django + Channels dashboard (browser UI)
│ │ ├── camera/ # Optional on-demand webcam snapshot service (USB/V4L2)
│ │ ├── watchdog/ # Daily service-restart / log-vacuum job
│ │ ├── globals/ # Shared config + git-ignored secrets
│ │ └── install/ # install.sh, systemd units, nginx configs
│ └── sensor/ # Arduino/PlatformIO firmware for the sensor nodes (the "wpw" project)
├── hardware/ # KiCad schematics + Gerbers for the Pi HAT and the sensor PCB
├── graphics/ # Logos, hardware labels, web-UI assets, renders
└── docs/ # Audits, protocol review, hardening notes, handoffsMaster — Raspberry Pi + "WaterPlant Pi" HAT (hardware/rpi hat/).
GPIO assignments live in code/master/core/hardware.py:
| Function | BCM pin(s) | Notes |
|---|---|---|
| Channel outputs | 16, 19, 20, 26, 21 | One per channel; CHANNEL_COUNT = 5 |
| Shared pump pin | 6 | Energised together with a channel in valve mode |
| Water-level sensor | 4 | |
| nRF24 CE / CSN / IRQ | 22 / 0 / 17 | SPI radio |
| Status LED / Fan | 14 / 15 |
A channel of type pump drives its own pin directly. A channel of type
valve drives its pin and the shared PUMP pin (one pump pushing water
through whichever valves are open); the pump pin is reference-counted so it
only drops once the last valve closes.
Sensor node — "Sensor" PCB (hardware/sensor/), firmware in code/sensor/:
TX build), deep-sleep
SLEEP_MODE_PWR_DOWN, woken by the 8-second watchdog timer.PB2), read on ADC PA3; calibration min/max stored in EEPROM.A7 against the internal 1.1 V reference; below
3.2 V the node sends a battery-alert frame instead of a normal reading.sudo apt install git -y
git clone https://github.com/airiclenz/tethys.git tethys
cd tethys/code/master/install
./install.shThe installer updates the OS, creates a Python venv, installs Redis + nginx,
runs the Django migrations, registers the systemd services, enables SPI, and
reboots (re-run ./install.sh --rebooted=true afterward if you decline the
auto-reboot). On first run it generates a random API key into the
git-ignored code/master/globals/secrets.py and prints it — copy it.
Useful flags:
--debug=true — developer install (also installs the TypeScript toolchain).--allowed-hosts=tethys.<tailnet>.ts.net — extra ALLOWED_HOSTS for remote
(VPN) access. Alternatively (and preferred per-install), list the name in the
git-ignored code/master/globals/allowed_hosts.py (copy allowed_hosts.example.py)
— no reinstall needed, just restart the services. See
docs/remote-access-hardening.md.After it's up: open http://<pi-host>/ in a browser → Settings (top menu) →
paste the API key → Save. The key gates every request (reads included; only
the CORS OPTIONS preflight is exempt), so the dashboard stays blank until it's
set. If a page can't authenticate (missing or wrong key), a warning banner
appears under the header with a link straight to Settings.
/) — per-channel reading history: two trend charts
(moisture % and battery voltage over time) sit above a sortable table of the
individual readings./channels/) — live moisture %, battery, last action per
channel; enable a channel, set its nickname, type (pump/valve), pump duration,
and the moisture trigger %; manually test a pump./schedules/) — define silent phases (quiet hours):
weekday + start time + duration windows during which all automatic watering
is suppressed (e.g. overnight). The header shows a sleep icon while a silent
phase is active./webcam/) — optional live view from a USB camera. Off by
default; flip Enable to refresh a snapshot every few seconds. Live-view
only — nothing is recorded, and the camera releases automatically when you
leave the tab or after an idle timeout. See Webcam below.Tethys can show a live webcam view on its own Webcam tab — handy for checking on plants — but it ships off and only captures on demand:
v4l2-ctl (no extra Python dependency). A Pi Camera Module (CSI) is not
wired up yet — the capture code keeps a seam (camera/captureBackend.py) for
adding it as one new class.fetch, gated by the same API key as everything
else). There is no video stream and no storage — purely a live view.CAPTURE_FOCUS / CAPTURE_ZOOM in camera/config.py set the
defaults a fresh browser starts from.409). It auto-releases the camera after a short idle window and
enforces a hard maximum on-time, mirroring the pump's auto-off safety — so the
camera can never be left on by accident.tethys-camera runs as the normal app user (no root,
unlike tethys-core), with only the video group added so it can open the
root:video /dev/video* nodes. The installer adds the user to that group and
installs v4l-utils.From code/master/install/:
./installServices.sh — re-deploy the systemd services from the current code../services-restart.sh, ./services-stop.sh, ./services-clearLogs.sh../deploy-static.sh — rebuild the frontend TypeScript and publish it to nginx.The six services: tethys-core, tethys-api, tethys-web, daphne,
tethys-watchdog (restarts core+api+camera nightly at 01:00 and vacuums the
journal), and tethys-camera (the optional on-demand webcam, off by default).
Built with PlatformIO from code/sensor/. Before
building, set the node identity in code/sensor/include/wpw_Config.h:
SENSOR_NUMBER (1–5). Flash with:
cd code/sensor
pio run -t program -e tethys-sensor # via an AVR ISP programmer⚠️ The master and every sensor must be flashed together. The wire format is versioned and fixed-size; a node on mismatched firmware fails the version check and goes silent (cleanly — it never corrupts data). See the Known limitations / coordinated flash note in
CHANGELOG.md.
A sensor node has no screen; its single onboard LED is how it talks to you.
Patterns are produced by DoSimpleBlink(onMs, offMs) and DoSoftPulse(ms)
(code/sensor/src/wpw_Blinker.cpp; the soft
pulse is a software-PWM glow, since the LED pin has no free hardware PWM).
How calibration works. Calibration maps the probe's raw reading onto a
0–100 % moisture scale, and it is two-point: the node captures one reading in
a wet condition and one in a dry condition, then stores the lower value as
MinimumValue (wettest) and the higher as MaximumValue (driest) in EEPROM
(Calibrate() in code/sensor/src/wpw_Sensor.cpp).
Later readings become 100 − map(raw, min, max, 0, 100) %.
sensorTriggerCalibration), then power-cycle or reset the node.
Calibration runs only as part of the boot-time config exchange (the full
GETCONFIG in setup()) — not on a normal measurement wake. A running node
re-pulls its config periodically (see Periodic config pull below), but those
pulls are deliberately settings-only: they carry measure-frequency /
TX-power but never the calibration trigger, so they can't run calibration
mid-life. The master delivers the trigger once and immediately clears the flag
(core/radio.py), so if you set the flag while the node is already running,
nothing happens until you reset it. (This is the usual reason a calibration
"didn't start".)First reading after boot. On every boot — including right after calibration —
the node waits ~1 minute (STARTUP_SETTLE_SECONDS, default 60, in
wpw_Config.h) before taking and transmitting
its first reading. It sleeps through the wait (you'll see the heartbeat flash
each ~9 s), so it costs almost no power. Put the probe in its final monitoring
position before this window ends — the first reported value reflects wherever the
probe is at that moment, not the calibration captures.
Periodic config pull. A running node re-pulls its config so web-UI changes
(measure frequency, transmission power) reach it without a power-cycle. The cadence
is PULL_CONFIG_CYCLE_SECONDS in wpw_Config.h
(default 3600 = 1 h; 0 disables it for maximum battery saving). To stay
gentle on the battery the pull rides an existing measurement wake — the radio
is already powered up, so it adds no extra power-up, and the master answers from
its warm ConfigCache so the listen window is usually a few milliseconds. The pull
therefore happens on the first measurement at/after the cycle has elapsed since the
last pull. These pulls are silent (no LED) and settings-only — they never
carry or clear the calibration trigger (calibration stays boot-only; see above).
The patterns, all on the sensor (TX) node LED:
| Event | Blink pattern | Meaning |
|---|---|---|
| Power-on / node ID | N slow blinks (150 ms on, 300 ms off), where N = SENSOR_NUMBER | Node booted and is powered; the count identifies which node it is (1–5). |
| Config received | a soft ~2 s glow (brightness ramps up then down) | The master answered the config request (at boot) and the settings were applied. |
| No master reply | two short flashes (15 ms) | Config request timed out (no answer within 500 ms); the node falls back to the settings stored in EEPROM. |
| Config rejected | three short flashes (15 ms) | The master replied but the frame failed the protocol-version / type check. |
| Settings saved | accelerating ramp — 150 → 100 → 75 → 50 → 25 → 15 ms — then one long 300 ms flash (ramps up to a strong finish) | New settings differed from EEPROM and were written. |
| Settings unchanged | decelerating ramp — 15 → 25 → 50 → 75 → 100 → 150 ms — fading out with no final flash (the mirror of saved) | Config was checked but nothing changed, so nothing was written. |
| Heartbeat | one very short flash (5 ms) each wake (~every 9 s) | Normal sleep/measure loop tick — the node is alive. |
| Transmit failed | four short flashes (15 ms) | The radio send failed; the node backs off (~15 min) before retrying. Part of the short-flash radio-error family, counted: 2 = no reply, 3 = bad frame, 4 = send failed (a successful config exchange is the soft glow above instead). |
| Calibration countdown | 10 slow blinks (300 ms) → 20 fast blinks (100 ms) → one long blink (1 s) → LED off while it measures | Counts down to a calibration capture. Runs twice, once per measurement point. |
| Calibration rejected | one very long flash (2 s) | The two calibration readings were too close together; calibration was discarded and the previous values kept. |
This half is the lightweight CLAUDE.md: the runtime topology, the invariants
that are easy to break, and where to look. Read it before changing anything on
the master.
Everything runs on the Pi as systemd services (units in
code/master/install/assets/*.service, nginx in *.nginx):
| Service | Process | Binds | Reached via |
|---|---|---|---|
tethys-core | core/tethys_core.py (asyncio) | — (GPIO/radio) | n/a |
tethys-api | gunicorn → tethys_api.wsgi | localhost:5001 | nginx :5000 |
tethys-web | gunicorn → tethys_web.wsgi | localhost:8000 | nginx :80 / |
daphne | ASGI → tethys_web.asgi | 0.0.0.0:8001 | nginx :80 /ws/ |
tethys-watchdog | watchdog/tethys_watchdog.py | — | nightly restart |
tethys-camera | camera/tethys_camera.py (stdlib HTTP) | 127.0.0.1:8002 | nginx :80 /camera/ |
| redis-server | Channels layer for daphne | localhost:6379 | — |
The API is the single source of truth. Both the core daemon and the web
backend treat it as the database boundary and reach it at http://localhost:5000/api/
(nginx → gunicorn:5001). It is Django REST Framework over SQLite
(api/db.sqlite3).
core/radio.py (checkRadioInbox → handlePayload) drains the RX FIFO,
validates each frame via core/protocol.py, and POSTs the reading to
sensorData/. On success it sets that channel's flag in
core/config.py::FlagHandler.channelFlags.core/actionEngine.py::handleActions (called each core-loop pass) checks the
silent phase first (fail-closed — see below), then for each flagged
channel pulls channelSummary/<n> and, if moisture <= trigger and the
channel is enabled, calls pumpController.run_pump(...).core/pumpController.py energises the line, arms a threading.Timer
auto-off, and on completion writes an actionLog/ entry — all non-blocking,
so the radio keeps listening while a pump runs.web/tethys_web/jobs.py polls the API every 10 s
(/lastUpdate/, /channelSummary/, /schedule/, /silentPhaseStatus/) and,
on a change, broadcasts to all browsers over the /ws/tethys/ WebSocket
(web/tethys_web/consumers.py).A manual Test Channel tap uses the same hardware path, not a separate one:
the API enqueues a ManualCommand (it never drives GPIO), the core drains it each
loop pass and runs it through the same pumpController (rejecting an activate when
another channel is already running), and writes the outcome back for the UI to
poll. This is what keeps the one-channel power limit holding for manual control —
see Invariants below.
When a sensor asks for its config (GETCONFIG), the master answers from an
in-memory ConfigCache (warmed out-of-band by Radio.refreshConfigCache), never
with a blocking HTTP call inside the sensor's short listen window. A running node's
periodic, settings-only pull (GETCONFIG_PERIODIC) is answered the same way but
with the calibration trigger stripped and never cleared.
MAX_PUMP_SECONDS = 300 and has
a guaranteed off (timer + try/finally). The controller drives all lines LOW
on construction (fail-safe boot) and on SIGTERM/atexit. Never reintroduce a
watering path that bypasses pumpController. make_controller() is the only
place lgpio is bound; all hardware goes through the GpioAdapter seam so the
logic is testable without a Pi.pumpController is the single owner
of the watering GPIO. Both automatic watering and the web UI's manual Test
Channel taps go through it — the latter via the ManualCommand queue the core
drains each loop pass — so its max_concurrent = 1 guard holds the invariant
that at most one channel (plus the shared pump pin) is ever energised, which
protects the power supply. A manual activate that arrives while another channel
is running is rejected, not run. The API process never drives GPIO itself.isInSilentPhase()
returns None and actionEngine skips watering for that pass — it must
never water on unknown state. The window math lives in
api/tethys_api/silentphase.py (stdlib-only, unit-tested independently of
Django).globals/config.py::TIME_ZONE (Europe/Stockholm) is the
single deployment/display zone, shared by core and web. The Django API
deliberately keeps TIME_ZONE = 'UTC' as its storage zone — schedule start
times are wall-clock times-of-day stored on a placeholder date; the deployment
zone is applied only at silent-phase evaluation. Don't "fix" the API to local
time. (See the silent-phase fixes in CHANGELOG.md for why.)core/protocol.py and
sensor/include/wpw_RXTX.h / wpw_Config.h must stay byte-for-byte in sync:
PROTOCOL_VERSION = 1, PAYLOAD_SIZE = 8, structs <BBBf> (sensor→master, 7 B)
and <BBHB?> (master→sensor, 6 B). Bump PROTOCOL_VERSION on any layout
change — and re-flash everything (no backward compat). Adding a new message
type (the type byte, e.g. DATATYPE_CMD_GETCONFIG_PERIODIC = 8) is not a
layout change, so it does not bump the version; keep the type constants in sync
on both ends. An un-updated peer ignores an unknown type (the sensor's periodic
pull just times out and retries), so mixed versions degrade gracefully.protocol.pipe_for_channel.CHANNEL_COUNT is the single source of truth (= len(Pins.CHANNELS) = 5).
The DB/radio reference 6 channels historically; a 6th needs a physical GPIO pin
first. Don't hardcode channel counts.globals/secrets.py (git-ignored) holds TETHYS_API_KEY,
API_SECRET_KEY, WEB_SECRET_KEY, and is read by both the Django apps and
the non-Django core. Template: globals/secrets.example.py. Every API request
needs X-API-Key (reads included; only CORS OPTIONS is exempt) — see
api/tethys_api/permissions.py::ApiKeyRequired.A single shared API key gates all requests, compared constant-time,
fail-closed. The core (apiInterface.py, radio.py) and the web backend
(jobs.py, tools.py) send it server-side; the browser stores it in
localStorage and common.ts injects it into every fetch. Transport is plain
HTTP on the LAN — for remote access, front it with a VPN overlay (Tailscale/
WireGuard), not an open port. See docs/remote-access-hardening.md.
api/tethys_api/models.py)number PK) — enabled, nickName, channelType (pump/valve),
actionTriggerPercent, pumpDurationSeconds, sensorMeasureFrequencyMinutes,
sensorTriggerCalibration, sensorTransmissionPowerLevel.channel FK, moisturePercent, batteryVoltage, timestamp.channel FK, actionType, startTime, endTime.weekday, scheduleType (silent), startTime (UTC wall-clock),
durationMinutes, enabled.channel FK, action (activate/deactivate), status,
message, requestedAt, processedAt. The web UI enqueues a manual Test
Channel tap here; the core drains it through pumpController (the API never
drives GPIO) and reports the outcome back for the UI to poll.ChannelType, ActionType, ScheduleType, TransmissionPowerLevel.channelSummary/<n> is the denormalized read both the core and UI consume
(channel config + latest sensor reading + counts).Backend (core + API) — pure Python, no Pi/hardware needed:
cd code/master
pip install -r install/python-requirements-dev.txt # pytest, dev-only
pytest # pytest.ini sets pathsCovers the parts that gate physical action or silent failure: core/tests/
(pump controller, GPIO adapter, protocol framing, action engine, manual-command
queue) and api/tethys_api/tests/ (API-key permission, silent-phase window math,
LAST_DATA_UPDATE wiring, manual-command lifecycle). All run without lgpio/pyrf24/a Pi, thanks to the
GPIO and protocol seams. Add tests behind those seams when you touch the
watering, radio-framing, or silent-phase paths.
The camera service has its own pure-Python suite (camera/tests/: the
fail-closed snapshot gate, idle / max-on auto-off, and the X-API-Key check, via
FakeSnapshotBackend). It is not in the default testpaths, so run it
explicitly: pytest camera/tests.
Web UI (Playwright) — browser-driven regression tests for the channels UI:
cd code/master/web
pip install -r requirements-dev.txt
playwright install --with-deps chromium # downloads Chromium + apt libs (needs sudo)
pytestThese drive a headless Chromium against a throwaway Django live server, exercising
the real compiled JavaScript (conftest.py compiles static/ts → static/js
first), so they catch DOM/click-handler bugs the Python suites can't. See
web/tests/README.md for what's covered and how
it works. A developer install (install.sh --debug=true) installs all of the above
automatically.
TypeScript sources in web/static/ts/ compile to web/static/js/ via
compile.sh (tsc) / compileAutomatically.sh (watch). The build is strict
(strict: true in tsconfig.json) and tsc -p tsconfig.json compiles cleanly.
Use install/deploy-static.sh to compile + publish to the nginx static dir. The
compiled web/static/js/ is git-ignored — it is rebuilt from the .ts sources on
deploy and before the UI tests, so the TypeScript is the single source of truth.
⚠️ Editing the frontend? Change the
.tsfiles inweb/static/ts/, neverweb/static/js/directly. The.jsfolder is generated output (git-ignored) and is overwritten on everytscrun / deploy, so any hand-edits to the.jsare silently lost. Tip:git check-ignore <path>will tell you if a file is a build artifact before you touch it.
CHANGELOG.md + docs/)SensorData column yet).picamera2 backend is deferred (seam left in
camera/captureBackend.py).docs/audits/ (code audit, architecture review, protocol review) and
docs/handoffs/ capture why the current shape exists — the pump-safety module,
the API-key lockdown, the protocol hardening, and the silent-phase timezone fixes.
CHANGELOG.md is the readable summary of each. Start there before reworking any
of the invariants above.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 4 | 1u | Airic_Lenz:Device_0603_Handsoldering | C1, C2, C6, C7 |
| 3 | 2k | Airic_Lenz:Device_0603_Handsoldering | R1, R2, R3 |
| 2 | 10k | Airic_Lenz:Device_0603_Handsoldering | R4, R5 |
| 2 | 4.7uF | Airic_Lenz:Device_0603_Handsoldering | C3, C4 |
| 2 | D_Small | Airic_Lenz:Diode_0603_Handsoldering | D2, D4 |
| 2 | Fiducial | Airic_Lenz:Fiducial | FID1, FID2 |
| 1 | 0.1u | Airic_Lenz:Device_0603_Handsoldering | C5 |
| 1 | 510k | Airic_Lenz:Device_0603_Handsoldering | R6 |
| 1 | ATtiny84-20SSU | Package_SO:SOIC-14_3.9x8.7mm_P1.27mm | U3 |
| 1 | BatteryConnector | Airic_Lenz:Connector_PinHeader_02x1 | J1 |
| 1 | C_Soil | Airic_Lenz:WaterPlant_C_Soil | C8 |
| 1 | Conn_01x06_Female | Airic_Lenz:Connector_FlatHeader-06x1_SPI | J4 |
| 1 | Drill_Hole | Airic_Lenz:Hole_M2_NPTH | DR1 |
| 1 | JK-SMD0603-005 | Airic_Lenz:Device_0603_Handsoldering | F1 |
| 1 | LED_BLUE | Airic_Lenz:LED_0805_Handsoldering | D1 |
| 1 | LED_RED | Airic_Lenz:LED_0805_Handsoldering | D3 |
| 1 | LOGO | Airic_Lenz:Logo_WiFi_Silk_4mm | G*** |
| 1 | LOGO | Airic_Lenz:WaterPlant Earth Back | G*** |
| 1 | LOGO | Airic_Lenz:WaterPlant Earth Front | G*** |
| 1 | Logo_Airic_Lenz | Airic_Lenz:Logo_AiricLenz_Silk_15mm | Logo1 |
| 1 | Logo_OSH | Airic_Lenz:Logo_OSHW_Silk_10mm | L1 |
| 1 | MCP1700-3002E_SOT23 | Package_TO_SOT_SMD:SOT-23_Handsoldering | U1 |
| 1 | MCP73831-2-OT | Package_TO_SOT_SMD:SOT-23-5_HandSoldering | U2 |
| 1 | PowerSwitch | Airic_Lenz:Switch_SMD_K3-1296S-E2 | J2 |
| 1 | USB_B_Micro | Airic_Lenz:Connector_USB_Micro_B_C77238 | J3 |
| 1 | nRF24L01 | Airic_Lenz:Module_nRF24L01_SMD | MOD1 |
It measures 39.3 × 145.9 mm and has 2 copper layers, 1.6 mm thick.
35 parts, built around the ATTINY84-20SSU, NRF24L01 and JK-SMD0603-005. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the airiclenz/tethys repository on GitHub, at the commit this page was made from: the board is hardware/sensor/Sensor.kicad_pcb. PCBKit opens the board in the browser, with nothing to install.
Its repository publishes it under the MIT licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).

airiclenz/tethys
Battery-powered wireless soil-moisture sensors report to a Raspberry Pi master over a 2.4 GHz nRF24 radio link. The master decides when to water, drives the pumps/valves, and serves a real-time web dashboard - all on…

openconcepts-ar/accel2d
This project aims to provide a graphics server system based on hardware accelerated graphics, and a easy way to develop the graphics primitives.





lukilukeskywalker/Binary_Clock
Binary Clock designed to run on an attiny84, and to be in a one sided pcb


aspro648/KiCad
My design files. Abandon all hope ye who enter here.