Skip to content

PR-09: HTTP REST/JSON API v1#15

Merged
cryptotomte merged 10 commits into
mainfrom
009-http-server-api-v1
Jul 5, 2026
Merged

PR-09: HTTP REST/JSON API v1#15
cryptotomte merged 10 commits into
mainfrom
009-http-server-api-v1

Conversation

@cryptotomte

Copy link
Copy Markdown
Owner

PR-09 — HTTP REST/JSON API v1

Delivers the frozen /api/v1/ REST/JSON contract (FR7, FR6, FR12) over esp_http_server, plus a committed
OpenAPI 3 sketch (docs/api/openapi.yaml). Behavioral coverage of docs/parity-checklist.md §4. Spec-kit
artifacts in specs/009-http-server-api-v1/. Depends on PR-06/07 (merged); live data from PR-03/04/05.

What it does

A dedicated esp_http_server in station mode serving /api/v1/:

  • Read: GET /status (mode, wifi+IP, time-sync, uptime, reset reason, firmware, storage[, power]),
    GET /sensors (env + soil + level [+ power] with per-section valid flags, epoch timestamp),
    GET /history (metric/reading/range|start-end → aligned series), GET /power (rev2), GET /events.
  • Control: GET/POST /pumps (capability-enumerated; start/run/stop; 300 s cap; already-running → 409),
    GET/POST /config (validated, all-or-nothing, persisted), mode ↔ wateringEnabled.
  • Diagnostics: POST /selftest (bounded env+soil self-test), POST /ota (501 stub — PR-13).
  • Consistent {success,…} / {success:false,error} envelope; JSON 404 and 405; unset clock → null epoch.

Architecture

  • New api component, split like sensors/network: pure serialize/parse/route-table/envelope
    (host-tested with cJSON) + target-only ApiServer (the sole esp_http_server touchpoint, opaque
    handle, excluded from the linux build). Handlers call ONLY the Locked* interfaces, are non-blocking
    (cached getters; /selftest is the one bounded exception), make no watering decisions, and never
    serialize the wifi password (Constitution I / FR-015). The server runs on the httpd task, not
    watchdog-subscribed; SNTP + API start once on the WiFi Connected edge.
  • cJSON: IDF v6 has no built-in json component → pinned managed dep espressif/cjson ==1.7.19~2
    (components/api/idf_component.yml); it links on the linux host so the pure layer is host-tested.

Verification

  • Host tests: 252/0 — serializers (all DTOs; NaN→null, NPK present-flags, rev1 power null, unset-clock,
    wifi-password-absent), request parse/validate (config all-or-nothing + bounds; pump action/duration),
    route-table↔OpenAPI drift guard, resolveEventCount/resolveWindow/DST-safe range helper, envelope.
  • Both board targets build + board-config verified; app rev1 1 057 920 B / rev2 1 060 496 B (~33 % free
    in the 1.5 MiB OTA slot). dependencies.lock adds only cjson; esp-modbus pins unchanged. No frozen-legacy edits.

Review (CP3)

Five specialist agents; all core invariants PASS. Findings A1–A9 + coverage/comment fixes applied and
re-verified (see docs/checkpoints/cp3-009-http-server-api-v1.md): start-on-Connected-edge (was a 10 Hz
retry storm), JSON 405, ApiStatus wired in, LockedWifiManager removed (reader-only lock gave no
guarantee), 400 on malformed query params, window-underflow clamp, config-500 logging, cJSON OOM guards.

Deferred (by design)

  • Two type polishes (SensorReadingsDto.timestamp→optional, PumpCommandResult→optional); driving the
    ApiServer registration array from the route table — a later cleanup.
  • Soil/power readings are last-good with valid:false until PR-11's periodic reader lands.
  • HIL (specs/009-http-server-api-v1/checklists/hil.md) runs on the rev1 rig: scripted curl over every
    endpoint incl. non-blocking sensors, pump timed-run+cap, config reject, robustness (bad-JSON 400,
    unknown 404, stalled-client watering-isolation), rev2 power.

🤖 Generated with Claude Code

cryptotomte and others added 10 commits July 5, 2026 14:12
Spec-kit artifacts for PR-09 (esp_http_server /api/v1/ REST/JSON API + frozen
OpenAPI sketch), approved at Checkpoint 2. Records: pure cJSON serialize/parse/
route-table (host-tested) + target-only ApiServer split; separate STA-mode server;
non-blocking cached reads (QUIRK 5); soil/power served last-good with valid=false
until PR-11; mode->wateringEnabled; LockedWifiManager; no auth v1; OTA stub.
No implementation yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ifiManager

Phase 1+2 of PR-09:
- api component (linux-guarded CMake): pure ApiEnvelope + ApiRoutes(matchRoute) +
  ApiDtos, host-tested; ApiServer target-only slot reserved
- cJSON: IDF v6 has no built-in json component -> pinned managed dep
  espressif/cjson ==1.7.19~2 (components/api/idf_component.yml); dependencies.lock
  updated (esp-modbus pins untouched)
- LockedWifiManager (network) for the cross-task API/console snapshot reader
  (resolves WifiState.h TODO(PR-09))
- host api suites registered (stubs); fixed /api/* -Werror=comment trap in headers

Verified: host 191/0, rev1 + rev2 build (0 errors) + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sted)

Phase 3 / US1 (pure, host-tested):
- ApiSerialize: serializeStatus/serializeSensors/serializePower(+unavailable) over
  cJSON via the success envelope; non-finite/last-good floats -> JSON null; NPK keys
  only when present; not-set clock -> null timestamp; rev1 power -> null; no wifi password
- 15 host tests (11 serialize + 4 route-table full-set); fixed another /*-in-comment
  (-Werror=comment) trap in ApiDtos.h

ApiServer + app_main wiring is the next mission (US1 target side).
Verified: host 206/0 (+15), rev1 + rev2 build + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ors/power

Phase 3 / US1 target side:
- ApiServer (target-only): esp_http_server in STA mode, opaque handle; GET /api/v1/
  status, /sensors, /power handlers reading ONLY non-blocking cached getters via the
  Locked* wrappers (QUIRK 5) + LockedWifiManager snapshot + esp_netif IP; JSON 404 via
  httpd_register_err_handler. Board-gated power (rev2 INA226 / rev1 unavailable).
- app_main: LockedWifiManager wraps wifi_manager_inst; ApiServer constructed after
  sensors, started once on first WifiState::Connected via SystemObserver (mirrors SNTP;
  object-ordering). Not WDT-subscribed; pumps_force_off() still first.
- validity derived non-blockingly (getLastError()==0 && isfinite) -> soil valid=false
  until PR-11, honest last-good elsewhere.

Verified: rev1 + rev2 build (0 errors) + board-config verified; host unchanged 206/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (host-tested)

Phase 4 / US2 (pure, host-tested):
- ApiRequests: parseConfigSet (all-or-nothing range validation per IConfigStore
  constants; malformed/wrong-type -> ok=false, nothing applied) + parsePumpCommand
  (action start/run/stop; duration 1..300) -> typed result structs, no throwing
- ApiSerialize: serializePump / serializePumpList / serializeConfig (never wifi password)
- 19 host tests (16 requests + 3 serialize)

ApiServer US2 handlers (pumps/config) are the next mission.
Verified: host 225/0 (+19), rev1 + rev2 build + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 / US2 target side (ApiServer):
- GET /api/v1/pumps (capability-enumerated); POST /api/v1/pumps/* (name from URI,
  parsePumpCommand -> runFor/stop; already-running -> 409; unknown pump -> 404)
- GET/POST /api/v1/config (parseConfigSet all-or-nothing -> setters; invalid -> 400,
  persist-fail -> 500). Mode = wateringEnabled. Never serializes the wifi password.
- non-blocking (pump status + config getters/setters only); no watering decisions
  (cap/no-restart live in IWaterPump); wildcard uri_match for /pumps/*.

Verified: rev1 + rev2 build + board-config verified; host unchanged 225/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nge helper

Phase 5 / US3 (pure, host-tested):
- ApiSerialize: serializeHistory (timestamps/values + echo; empty series -> empty
  arrays, not error), serializeEvents (newest-first), serializeSelfTest (overall+checks)
- namedRangeToWindow (ApiRequests): 1h/6h/24h/7d/30d -> [now-span, now]; unknown -> false
- 5 host tests

ApiServer US3 handlers + OpenAPI sketch + docs are the next mission.
Verified: host 230/0 (+5), rev1 + rev2 build + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 / US3 target side + polish:
- ApiServer: GET /api/v1/history (query metric/reading/range|start-end -> getSensorReadings,
  empty=200 not error), GET /events (bounded count), POST /selftest (the one documented
  bounded blocking read of env+soil), POST /ota (501 stub)
- docs/api/openapi.yaml: OpenAPI 3 sketch of all 11 endpoints (FROZEN at merge)
- firmware/CLAUDE.md: HTTP API v1 section
- specs/009 HIL curl checklist

Verified: host 230/0; rev1 1057920B (33% slot free) + rev2 build, both config-verified;
esp-modbus pins unchanged; only cjson added to dependencies.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses CP3 findings A1-A9 + doc corrections (host 230/0, both boards green):
- A1: system_observer starts SNTP + API server on the Connected EDGE (once per
  transition), not 10x/s on steady Connected (was a log flood + heavyweight
  httpd_start in the watchdog-fed watering loop)
- A3: ApiStatus wired in (added 409/500, dropped 503, statusLine()); ApiResponse.status
  is now the enum, no raw status literals
- A4: deleted LockedWifiManager (reader-only mutex gave no real guarantee); ApiServer
  reads WifiManager::snapshot() directly; WifiState.h comment corrected
- A5: JSON error body for 405 (was httpd default HTML — parity violation)
- A6: resolveEventCount + resolveWindow extracted to pure ApiRequests; history/events
  return 400 on present-but-invalid params instead of silently defaulting
- A7: clamp window underflow (unset clock can't wrap uint32)
- A8: ESP_LOGE the config-persist 500 with the failing field
- A9: cJSON OOM guards (no empty-body 200; no leak)
- serializeStatus emits epoch null when unsynced; corrected route/dispatch/US1-only/
  LockedWifiManager comments + CLAUDE.md + openapi header

New host tests for the extracted logic + coverage gaps land in the next commit.
Verified: host 230/0, rev1 + rev2 build (0 errors) + board-config verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- route/openapi drift guard: apiRoutes() set == checked-in expected mirror (A2;
  covers the previously-dead apiRoutes()/apiRouteCount())
- resolveEventCount / resolveWindow (range precedence, explicit both/start-only/
  end-only, default-24h, unknown->ok=false) + namedRangeToWindow underflow clamp (A6/A7)
- parser gaps: pump missing/wrong-type duration + missing/non-string action;
  config wrong-type bool, moisture 0/100 bounds + negative reject, interval floors
- envelope: errorBody/notFoundBody shape + statusLine mapping

Verified: host 252/0 (+22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cryptotomte cryptotomte merged commit 80ad953 into main Jul 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant