This project is an offline-capable smart home control & telemetry stack composed of:
- PostgreSQL database (state, telemetry, configuration)
- PHP (Caddy + PHP-FPM) thin JSON endpoints
- Bun WebSocket server for real-time encrypted device ↔ server messaging
- Vite + Vue 3 + Pinia frontend (plaintext WS consumption and UI controls)
- ESP32 firmware devices (room stats sensor, lux sensor, door sensor, main lights controller, AC controller) using AES-128-GCM encryption
- ESP32 device connects to WS server, sends identification
{type:"esp32_identification", channel, device_api_key}. - Device sends encrypted frames
{channel, device_api_key, nonce, payload, tag}(payload = hex ciphertext of JSON body via AES-128-GCM; tag = 16-byte auth tag). - Server decrypts, validates, updates
device_last_seen, and broadcasts simplified plaintext to all frontends. - Frontend dispatches user commands (lights, AC) in plaintext WS messages; server encrypts per-device when forwarding to ESP32.
- Periodic or event-driven values are persisted through PHP REST-like endpoints into PostgreSQL.
| Aspect | Choice | Rationale | Alternatives Rejected |
|---|---|---|---|
| API Layer | Thin PHP endpoints | Fast iteration, low overhead, simple PDO usage | Full Node REST API (would duplicate WS server responsibilities) |
| Real-time | Dedicated Bun WS server | Clear separation of stateless WS logic and stateful DB writes | Embedding WS inside PHP stack (complex) |
| Encryption | AES-128-GCM device ↔ server only | Confidentiality + integrity; keeps secrets server-side | Frontend encryption (would leak keys); TLS-only (still leaves LAN plaintext) |
| Data Storage | PostgreSQL | Strong relational and JSONB support | SQLite (concurrency), MySQL (no advantage here) |
| Frontend | Vue 3 + Pinia | Reactive store pattern, lightweight, Vite dev speed | React (heavier for small scope) |
| Device Messages | Per-channel JSON inside encrypted envelope | Extensible, easily debug via simulator | Binary custom protocol (harder maintainability) |
backend/ # PHP + Caddy configs
php/ # Endpoints (stateless, validated)
Caddyfile # Static & fastcgi + CORS headers
jsServer/ # Bun WebSocket server (encryption, routing)
frontend/ # Vue 3 application (Pinia stores, components)
IOT/ino/ # ESP32 sketches (one per device type)
postgreSQL/ # DB Docker build + init.sql schema + seed
docs/ # Documentation (this file, payload references, etc.)
| Table | Purpose | Key Columns |
|---|---|---|
devices |
Device identity, encryption keys, heartbeat | device_api_key, device_encryption_key, device_last_seen |
room_statistics |
Periodic environmental readings | stat_date, stat_temperature, stat_humidity, stat_pressure |
door_status |
Event log (open/close transitions) | status_date, status_name |
profiles |
Saved aggregate configuration profiles | profile_name, profile_json |
blinds_config |
Single-row blinds automation thresholds | min_lux, max_lux, automate |
Defined in postgreSQL/init.sql; container runs it on first launch. Extend by adding migrations or altering the init script (for dev resets).
{ "type": "esp32_identification", "channel": "room_stats", "device_api_key": "<apiKey>" }
{ "channel": "room_stats", "device_api_key": "<apiKey>", "nonce": "<24-hex>", "tag": "<32-hex>", "payload": "<cipher-hex>" }
Decrypted JSON body shapes per channel (examples):
door_sensor:{ doorOpen: true }room_stats:{ temperature: 23.4, humidity: 55.1, pressure: 1012 }main_lights:{ lightON: true }lux_sensor:{ lux: 347 }air_conditioning:{ requestedTemp, function, klimaON, manualOverride }
- Door:
{ channel:"door_sensor", payload:{ doorOpen:bool } } - Stats:
{ channel:"room_stats", temperature, humidity, pressure } - Lights:
{ channel:"main_lights", lightON } - Lux:
{ channel:"lux_sensor", lux } - AC:
{ channel:"air_conditioning", payload:{ requestedTemp,function,klimaON,manualOverride,currentTemp } }
{ "channel": "main_lights", "lightON": true }
{ "channel": "air_conditioning", "payload": { ... } }
Server encrypts commands per recipient device.
Algorithm: AES-128-GCM (16 byte key from device_encryption_key column)
Per message:
- 12-byte random nonce (hex
nonce– 24 hex chars) - 16-byte auth tag (hex
tag– 32 hex chars) - Hex ciphertext of JSON body (
payload) - Optional
alg: "AES-128-GCM"for clarity
Operational Notes:
- Keys cached and refreshed every 5 minutes (or on server restart)
- GCM provides authenticity; modified ciphertext/tag causes decrypt failure
- Nonce uniqueness per device enforced via hardware RNG; no padding required
- Frontend remains plaintext to avoid exposing keys
Example frame:
{
"channel":"door_sensor",
"device_api_key":"<key>",
"nonce":"a1b2c3d4e5f60718293a4b5c",
"payload":"7fa9...",
"tag":"d3c2b1a09f8e7d6c5b4a392817161514",
"alg":"AES-128-GCM"
}All endpoints now:
- Include
bootstrap.phpfor headers & helpers. - Enforce method with
require_get()/require_post(). - Parse JSON via
read_json(). - Return
{ success: true, ... }on success or{ success:false, error:"message" }with appropriate HTTP status.
Helper functions in bootstrap.php:
send_json($data, $code=200)fail($message, $code=400)get_pdo()(singleton PDO)- Validation wrappers and method guards.
| Store | Purpose |
|---|---|
wsStore |
Provides WebSocket URL from env. |
linkStore |
Builds backend PHP endpoint URLs. |
doorStatusStore |
Tracks last door state (debounced persistence). |
saveStatsStore |
Hourly (or forced) room stats persistence. |
automateStore |
Blinds automation state UI binding. |
Utility modules:
utils/api.js: unifiedgetJson,postJsonreturning normalized result objects.utils/wsHelpers.js: safe message parsing + type guards.
enqueueStatus()collects rapid toggles; flush after adjustable delay (default 5000 ms) or immediate if delay set to 0.
- Stores the most recent reading; after one hour (or forced save) sends to backend if last save timestamp expired.
Each sketch:
- Connect Wi-Fi, open WS.
- Identify (
esp32_identification). - Assemble JSON → encrypt with key + random 12-byte nonce (GCM) → envelope with nonce + tag.
- Send periodic telemetry or event updates.
- Decrypt & authenticate inbound commands (lights / AC) and act.
Libraries & Crypto:
- Custom
AESCryptowrapper around mbedtls performing AES-128-GCM (no padding). - Nonce generation: 12 bytes from
esp_random()seeded by Wi-Fi hardware RNG.
- Docker + Docker Compose
- Bun (for local dev of WS or frontend if outside container)
- Node (optional, not required if using Bun exclusively)
.env (example keys):
FRONTEND_PORT_EX=8882
BACKEND_CADDY_PORT_EX=8883
BUN_API_PORT_EX=3000
POSTGRES_PORT_EX=5433
VITE_WS_URL_PREFIX=ws://localhost:3000
VITE_BACKEND_URL_PREFIX=http://localhost:8883/
VITE_WLED_URL_PREFIX=http://<wled-ip>
A single root .env file now drives:
- Docker Compose service build args & exposed ports
- PHP backend (via
backend/php/config.phpwhich reads DB + Tuya credentials from environment) - Bun WebSocket server internal PHP calls (
BACKEND_INTERNAL_URL) - Frontend runtime variables (
VITE_*consumed by Vite)
See .env.example for a complete template including:
- Database / PgAdmin (
POSTGRES_*,PGADMIN_*) - Ports (
*_PORT_EX,*_PORT_NATIVE) - Frontend dev server (
PORT_REFERENCE,HOST_REFERENCE) - Runtime Vite vars (
VITE_BACKEND_URL_PREFIX,VITE_WS_URL_PREFIX,VITE_WLED_URL_PREFIX) - Internal service URL (
BACKEND_INTERNAL_URLfor server-to-server HTTP inside the Docker network) - Tuya credentials (
TUYA_CLIENT_ID,TUYA_CLIENT_SECRET,TUYA_DEVICE_ID,TUYA_API_ENDPOINT) - Simulator override (
SIM_WS)
Secrets (Tuya credentials, DB password) should be rotated and never committed with production values—use deployment-specific .env managed via secrets tooling (Vault, Docker swarm secrets, etc.).
docker compose up -d
Access:
- Frontend: http://localhost:8882
- PHP Endpoints: http://localhost:8883/.php
- WebSocket: ws://localhost:3000
- pgAdmin (if configured): expose relevant port
cd frontend
bun install
bun run dev
cd jsServer
bun install
bun bunServer.js
Use the built-in simulator to emulate an ESP32 sending an encrypted frame:
cd jsServer
bun run testDeviceSim.js room_stats <device_api_key> <16charEncryptionKey>
Replace room_stats with one of: door_sensor, lux_sensor, main_lights, air_conditioning.
What happens:
- Script generates a 12-byte nonce, encrypts a channel-appropriate JSON using AES-128-GCM
- Sends identification + encrypted payload
- Logs any decrypted server responses (e.g. AC temperature pushes)
If you tamper with
payloadortagbefore it reaches the server, decryption will fail silently (integrity check).
| Scenario | Approach |
|---|---|
| Verify encryption | Use jsServer/testDeviceSim.js to emit encrypted frames |
| CORS issues | Confirm Caddy headers + ensure correct endpoint filename (not just base URL) |
| Device not updating | Check WS logs for identification; ensure key present in DB |
| Blinds config invalid | Endpoint returns 400 with validation message |
| Room stats not saved | Check localStorage timestamp & network tab; forced save via store forceSaveNow() |
bun run jsServer/testDeviceSim.js room_stats <apiKey> <16charKey>
- Forgetting to append
.phpfilename → root POST fails CORS/404. - Using stale encryption key after DB update (wait for 5 min refresh or restart WS server).
- Large drift in device clock irrelevant (server timestamps all DB writes).
- Define device JSON body schema.
- Update device firmware to send encrypted envelope.
- Add handler branch in
bunServer.js(decrypt, validate, broadcast). - Create Vue component + store if needed.
- Optionally add persistence endpoint & DB table.
- Copy minimal template:
<?php
require_once __DIR__.'/bootstrap.php';
require_post();
$data = read_json();
// validate ...
$pdo = get_pdo();
// perform action
send_json(['success'=>true]);
?>- Expose via front-end using
getPhpApiUrl().
- Update profile JSON creation in frontend.
- No schema migration needed (JSONB flexible), but document new key in payload reference.
- Encryption keys never exposed to browser.
- All device frames validated after decrypt for expected shape.
- Potential future: rate limiting device messages, signature/HMAC to detect tampering (CBC alone doesn’t authenticate), rotate keys via DB update + short overlap.
- WS broadcast loops scale linearly with connections; acceptable for low device count (lab / home). For scale-out: consider tracking per-channel subscriber sets.
- DB writes batched logically: door status only on change (debounced on frontend), stats hourly.
| Category | Idea |
|---|---|
| Security | Add HMAC (Encrypt-then-MAC) for authenticity |
| Observability | Structured JSON logging & log rotation |
| Frontend | Toast notification system for failed saves |
| Backend | Pagination for getDoorStatus.php (historic trimming) |
| WS | Heartbeat timeout detection & auto-clean stale connections |
| Database | Add indices (e.g., CREATE INDEX ON room_statistics(stat_date DESC)) |
| Docs | Diagram images for architecture + sequence flow |
| Term | Definition |
|---|---|
| Envelope | Outer JSON wrapper containing channel, nonce, tag, payload (ciphertext) |
| Frontend broadcast | Plaintext WS message from server to browsers |
| ESP32 identification | Initial unencrypted JSON to bind connection to device key |
| Key refresh | Periodic reload of encryption keys from DB every 5 minutes |
| Symptom | Likely Cause | Fix |
|---|---|---|
| CORS error on POST | Missing .php in fetch URL |
Use linkStore.getPhpApiUrl('file.php') |
| Decryption failure log | Wrong key / truncated payload | Verify DB key & device firmware constant |
| No room temperature in AC payload | lastRoomTemperature not yet set |
Wait until first room_stats frame arrives |
| Door status duplicates | Not using debounce | Use enqueueStatus() |
| Blinds config save 400 | min_lux >= max_lux |
Adjust values |
- Rotate device keys quarterly (update DB + reflash devices)
- Vacuum / analyze DB growth (door & stats tables) monthly
- Review logs for decrypt warnings
- Rebuild containers after dependency updates
This architecture balances simplicity (thin PHP, plaintext frontend) with secure device communication (encrypted WS payloads). Modularity allows incremental enhancement (auth, metrics, HMAC) without redesigning core flows.
Generated as part of engineering thesis documentation. Extend and version control this file with any architectural changes.