Commit Graph

101 Commits

Author SHA1 Message Date
Indiana
7d3a6e695d feat: open the door — no sign-in wall, ever
Point and shoot. SeancePage no longer redirects a stranger to /enter; it
silently provisions a wanderer (POST /auth/guest, a real account) and drops
them straight into the séance. The landing CTA goes to /seance
unconditionally. Registering is now what it should always have been: how
you *keep* a codex and unlock device pairing — not the toll to get in.

Details that matter:
- One auto-attempt only, guarded by a ref: survives StrictMode's double
  effect, and a failure (rate limit, offline) doesn't retry forever.
- The loading veil covers provisioning, so there's no flash of an empty
  séance while the account is created.
- If provisioning genuinely fails, it falls back to /enter rather than a
  blank screen — the manual door still exists.
- A returning visitor already holds a cookie, so this never fires for them.

App.test's "sends anonymous visitors to /enter" assertion was inverted to
assert the open door instead — that test encoded the wall we just removed.

Verified live: POST /auth/guest returns wanderer-a6f1 and that cookie
authenticates on /auth/me. 366 frontend tests pass; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:01:27 +00:00
Indiana
2a67684df4 chore: dead-code + duplicate sweep; fix moon-flaky re-contact test
Dead code: removed an unused `settings` import (main.py), an unused
`RITUAL_HOLD_MS` import (RitualPanel), and an unused `beforeEach`
(SigilDesigner test). The `_refs` keep-alive in sdr.ts is deliberate
(holds hardware-pass constants) and stays; the orphaned .evp-scope /
.radio-waterfall CSS was already removed in an earlier lint pass.

Duplicate: coldSpot.ts and baseline.ts each carried the same time-aware
EMA alpha formula. Extracted it as baseline.emaAlpha(dtMs, tauMs) and
pointed both at it. coldSpot's pure-function core is deliberately NOT
merged into the stateful ThresholdBaseline class — different contract
(immutable-state-threaded vs internal-threshold), and forcing them
together would be an overhaul that risks the tested cold-spot logic.

Determinism fix: test_familiar_presence_answers_again_on_a_known_channel
pinned RETURN_CHANCE=1.0 but not the sky. Since the astronomy wiring made
the real return chance RETURN_CHANCE*(1 - veil_thinness*PULL), and
veil_thinness reads the *actual current moon phase*, a full-moon test run
dragged the effective chance to ~0.55 and the test failed ~45% of the
time. Now also pins VEIL_THINNESS_PULL=0 to isolate re-contact from the
veil influence (which has its own tests). Verified 12/12 consecutive
passes; it was ~7/12 before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:14:39 +00:00
Indiana
a5ee2d57cd docs: bring the README up to the app it actually describes
The old one predated roughly half the system: entropy, astronomy,
geomagnetic, manifest speech, voice archetypes, rituals/judgment/essence,
the ESP32 node, guest access, the Ghost Log, conditions, PWA, phone
layouts. Test counts corrected (54->328 backend, 110->366 frontend), the
protocol tables now list every real frame and endpoint, and Current State
distinguishes verified-live from unverified-on-hardware from
built-but-unreachable honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:25:21 +00:00
Indiana
0132c8a5bf feat: legibility — conditions, hints, kinder errors (Workstream B)
GET /api/conditions surfaces what the backend already computes: moon
phase, veil thinness, geomagnetic state — junk lon degrades to moon-only
instead of a 422, missing NOAA data means fewer lines, never an error.
VeilConditions renders it in the séance side column, polling every 10 min.

ModeHint: one in-fiction line per mode after 15s of an unused sensor,
dismissed forever via localStorage. Error copy in evp/radio now
detect-and-redirects (mic denied -> 'the board needs no ear'; no WebUSB
-> try EVP) instead of dead-ending.

No geolocation prompt from the conditions strip — asking for location
from a passive readout would be hostile; ?lon= stays supported for
callers that have it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	frontend/src/pages/SeancePage.tsx
2026-07-28 19:19:44 +00:00
Indiana
eaa28b20e8 feat: legibility wave — veil conditions, first-run hints, kinder errors
Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors):

- GET /api/conditions (new backend/app/routes/conditions.py): composes
  celestial veil_thinness with the cached NOAA Kp reading; lenient lon
  parsing (junk degrades to moon-only, never 422); geomagnetic may be
  null on a cold cache. Route tests stub the cache — no live NOAA calls.
- VeilConditions strip in the séance side column: moon glyph + phase,
  % lit, veil-thinness phrase, Kp line only when data exists. Polls
  every 10 min; renders nothing while loading; no error state.
- ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed
  once the mode's sensor runs this session, dismissal persisted in
  localStorage (qm_hint_<mode>). One mount line per panel.
- Error copy upgraded to detect-and-redirect: mic denied points at site
  settings and the ouija board/wire; WebUSB-unsupported suggests EVP.
- i18n en/es parity for every new string; coverage-check template
  domains extended for the new template-key call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:31:10 +00:00
Indiana
ac11fc5417 feat: PWA manifest + the Ghost Log (Workstream C)
manifest.webmanifest referencing the icon assets that actually exist
(sizes verified with file, not asserted), standalone display, no service
worker — offline séance is meaningless and SW cache bugs are not worth it.

GET /api/seances/recent: last 12 sessions in exactly three queries
(sessions+entity join, one GROUP BY for counts, one window-function query
for up to 3 echoes per session). Echoes filter on utterance payload kinds
because DB event kinds carry no greeting/manifest — the agent verified
where _speak actually writes rather than trusting the spec's phrasing.

/log page: entity glyphs, relative in-fiction timestamps, counts, echo
lines in transcript style, gated like the séance, 390px-safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:20:51 +00:00
Indiana
3b33b5d6f6 feat: presence beyond the tab — PWA manifest and the Ghost Log recap
Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:55 +00:00
Indiana
28fdc012f3 feat: the vessel whispers in the séance (Workstream D)
DeviceWhisper: a compact live panel in the séance side column, shown only
when the seeker has paired hardware. One DeviceFeedSocket, readings folded
through the coldSpot cores, online dot with a 15s tick so a silent device
goes dark, cold-spot/pressure flags when active. Renders nothing on zero
devices or any fetch failure — the séance never errors because of this
panel. Socket closed on unmount.

Uses the real GET /api/device endpoint — the spec said /api/devices,
which does not exist; the agent verified against routes/device.py rather
than trusting the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:50 +00:00
Indiana
b324aafec7 feat: the vessel speaks in the séance — DeviceWhisper panel
Workstream D of the usability wave (#8). A compact live panel in the
séance side column, shown only when the seeker has paired hardware:
device name, online dot (reading within 60s), latest readings in the
terminal readout style, and cold-spot / pressure-anomaly flags from the
lib/coldSpot.ts cores. Fetches GET /api/device once on mount; renders
nothing on zero devices or fetch failure — the séance never sees an
error from this panel. Socket closed on unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:13 +00:00
Indiana
9d605acc92 feat: guest passage — slip through as a wanderer (Workstream A)
POST /auth/guest creates a real User row (wanderer-<hex>, unusable random
password) and issues the normal session cookie, so every downstream system
— essence, codex, devices — works for guests unmodified. Per-IP limited
(5/hour, Cloudflare-aware) with username-collision retry. EnterPage gains
the wanderer button; the séance shows a dismissible claim-a-name nudge
keyed off the username prefix. The auth gate on /seance is untouched —
the guest button is the path through, not around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:18:46 +00:00
Indiana
f5320fcdec feat: guest passage — slip through as a wanderer
POST /auth/guest mints a real user row (wanderer-<4 hex>, collision
retry, unusable random password) and issues the normal session cookie,
per-IP rate limited at 5/hour. EnterPage gains the guest action;
the séance shows a dismissible claim-a-name note for wanderer- users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:18:16 +00:00
Indiana
7d24097141 docs: contract spec for the usability wave (guest access, legibility, recap, device bridge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:12:52 +00:00
Indiana
2e2c64d36b feat: phone layout for the séance screen
The page had a viewport tag and a 960px breakpoint that stacked the
columns for tablets, but nothing below it — so everything from 320px to
960px shared one layout. Real phones sit at 390-430px, where three things
actually broke:

  - Five mode tabs on one row. They now scroll horizontally instead of
    wrapping into a stack tall enough to push the board off screen.
  - The ask row (text input + ask + summon) crammed onto one line. The
    input now takes the full row and the two buttons share the next.
  - The four judgment verdicts sat two-up, leaving each too narrow for its
    rune plus label. They go full width — these are irreversible,
    consequential choices and must not be mis-tapped.

Every interactive target now clears 44px (Apple's HIG floor; Android's
48dp is close enough that one rule serves both), and the ask input is
exactly 16px because iOS Safari zooms the whole page when a focused input
is any smaller and leaves the layout zoomed after blur.

A second breakpoint at 380px handles SE-class phones, mainly by giving the
board less height so the transcript stays visible without scrolling.

Audited the rest first rather than assuming: the many `max-width` rules
are mobile-safe (they cap, they don't force), and InventoryPanel's grids
already use auto-fill/minmax and self-collapse. Only the fixed
`repeat(2, 1fr)` grids needed touching.

355 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:41:00 +00:00
Indiana
12e2d36527 refactor: extract the shared rolling-baseline core
BleFieldCore and MagFieldCore had converged on the same class: same
time-aware EMA, same warm-up gate, same "deviation past a threshold is an
event" shape, differing only in constants and field names. Three copies of
the alpha derivation existed across the sensor libs.

lib/baseline.ts now owns it. Both wrappers keep their own public types
(`.rssi`, `.magnitude`) so nothing downstream changed — which is what let
all 26 existing tests pass completely unmodified against the refactor.
That was the point of doing it this way: if the tests had needed editing,
the refactor would have been changing behaviour rather than removing
duplication.

coldSpot.ts deliberately does NOT adopt this. It threads immutable state
through pure functions so a whole session's narrative can be replayed in a
test without a clock — a different and equally valid shape. Collapsing the
two would force one into a style that doesn't fit it, which is how
deduplication turns into damage.

355 frontend tests pass unchanged; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:36:24 +00:00
Indiana
cf602ab3de feat: the moon shapes who answers; wire magnetometer; drop dead CSS
MOON INFLUENCE ON SUMMONING

Astronomy previously only decided whether a channel's familiar spirit
returned. It now shapes *who comes through*:

  - Rarity skews with real moon illumination. At full moon the rare and
    mythic weights roughly triple while common recedes, so a mythic
    summoning becomes a reason to go out on the right night rather than a
    flat lottery. Deliberately a skew and never a gate — every tier stays
    reachable on every night, because someone who can only play midweek
    should not be locked out of the good spirits.
  - Hidden traits take a small moonlit nudge: power and volatility rise,
    alignment drifts slightly darker. Capped at 0.12 and clamped to [0,1],
    so a full moon intensifies what a spirit already is instead of
    rewriting it. Deceptiveness is untouched — whether a spirit lies is its
    own nature, not the sky's doing.
  - The mint prompt is told the phase, and explicitly told the entity must
    never mention or seem aware of it. It shapes who they are, not their
    dialogue; a ghost remarking on the moonlight would break the illusion
    instantly.

Tests assert the outcomes shift in practice (mythic rate over 4000 draws,
rare-tier counts across 300 fallback profiles), not merely that the code
runs. test_mint_prompt_never_receives_traits now allows `sky` while still
forbidding `traits`: moon phase is public, observable state anyone can look
up, hidden ground truth is not.

GEOMAGNETIC (app/geomagnetic.py)

Real NOAA SWPC planetary K-index, verified against the live endpoint —
which caught a real bug: I had written the parser against an
array-of-arrays shape, and the actual feed serves a list of objects
(`estimated_kp` float, `kp_index` int, `kp` a display string with a letter
suffix). Fixed, and the tests now use the real captured shape. Cached,
never blocking, and a failed refresh keeps serving the last real value —
an hour-old genuine measurement beats nothing, and geomagnetic conditions
do not change fast enough for that to mislead.

MAGNETOMETER WIRED

MagnetometerListener existed but was never connected. The EMF panel now
runs it alongside the motion listener where the hardware exists, so the
"EMF meter" measures actual magnetic field in µT rather than only
inferring disturbance from movement. Additive: the motion path is
untouched and remains the only option on iOS. Its field jitter also feeds
the entropy pool.

DEAD CODE

Removed .evp-scope and .radio-waterfall, orphaned when both panels moved to
the shared SpectrumScope. Audited every other flagged export first and left
them alone — they are used internally, and "not imported elsewhere" is not
the same as dead.

Adds docs/CHANNELS.md recording what each channel measures and, honestly,
what has actually been verified against hardware versus only written
carefully.

311 backend + 355 frontend tests pass; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:31:15 +00:00
Indiana
b8e69b4bd3 feat: the room decides — physical entropy, real astronomy, unprompted speech
Three changes that together replace "deterministic hash decides everything"
with "the physical world genuinely participates".

PHYSICAL ENTROPY (app/entropy.py, lib/entropy.ts)

Contact was a database lookup: signature_from_anomalies() hashed the
anomaly pattern, so identical conditions always produced an identical
spirit. Now the client harvests real thermal/acoustic/RF noise from the
microphone and receiver noise floors — Von Neumann debiased, SHA-256
conditioned — and contributes it to every summon.

The client is untrusted by construction. A contribution is never a seed:
every draw is HMAC-SHA256(fresh server secret, client bytes || context).
Because fresh CSPRNG server bytes are always present, the output is
unpredictable and uniform no matter what the client sends — all-zeros, a
replayed value, or one chosen adversarially. The room can only ever ADD
unpredictability, never steer the result. Tests assert this directly:
400 replays of one contribution stay uniformly distributed.

A signature now identifies a *channel*, not a spirit. Whether the familiar
presence answers or something else picks up is a real draw
(RETURN_CHANCE). The Codex stays collectable; it is just no longer
guaranteed. test_same_signature_recontacts_same_entity became two tests —
one pinning the probability to prove re-contact works, one pinning it to
zero to prove something else can answer — because at 0.72 the original
would have passed ~72% of the time, which is worse than failing.

REAL ASTRONOMY (app/celestial.py)

Moon phase from the standard mean-synodic approximation, and true solar
midnight from the seeker's own longitude — the real witching hour for
where they are standing, not clock 3am. Computed, never fetched: an API
that can fail would mean the veil silently changes behaviour during
someone else's outage. Validated against published ephemeris dates (2024
full moons, 2025 new moons) rather than against its own output. A thinner
veil erodes the familiar presence's claim on a channel, so a full moon at
solar midnight makes strangers likelier. Only longitude is kept, never a
full coordinate; a denied location degrades to moon-only, silently.

GENERATION FROM NOTHING (SpiritService.manifest)

Not chat_stream with an empty question. The prompt contains no seeker
input at all — only measured room state, rendered as measurements
("deviation above the floor: 31.4") rather than interpretations
("terrifying spike"), so the horror comes from the entity instead of from
us. And the Ollama `seed` is derived from the physical entropy harvested
in that room, which fixes the token-sampling path: the room genuinely
selects the words. Change the noise, get different speech. Two rooms
cannot produce the same utterance.

Rendered as an intrusion rather than a reply — violet edge, full opacity
against the faded ambient murmurs, brief blur-in. The unsettling part is
that it is perfectly clear and completely unbidden.

Also fixes a hang I introduced: the two new summon tests consumed the
shared module-level per-IP budget, so test_summon_rate_limited_* blocked
forever on an entity frame that had been rate-limited away. They now scope
their own limiters.

264 backend + 355 frontend tests pass; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 05:38:59 +00:00
Indiana
30694a954a feat: bluetooth and magnetometer channels — two more real instruments
Both measure genuine physics rather than dressing up a random number.

Bluetooth (lib/bluetooth.ts): BLE advertises in the 2.4GHz ISM band, and
the human body is mostly water, which absorbs 2.4GHz strongly — the same
physics that makes a microwave work and that degrades your wifi when
someone stands between you and the router. So RSSI genuinely drops when a
body crosses the path. That makes signal-strength variance a real,
physically-grounded movement signal. The module reports exactly that and
nothing more: it never claims a drop *is* a presence, only that the field
changed. Threshold is 6dB — above the 2-4dB of idle multipath wander, and
inside the 3-10dB a real body actually causes.

Magnetometer (lib/magnetometer.ts): the existing EMF mode infers field
disturbance from DeviceMotion/DeviceOrientation, which is a real
measurement but measures *movement*, not magnetism — a phone sitting still
beside a running motor reads nothing. This reads the actual magnetometer,
so the EMF meter measures what an EMF meter is supposed to. Real
ghost-hunting EMF meters are just magnetometers, and the spikes they pick
up come from mains wiring, motors and moving ferrous mass — all of which
this picks up, for the same real reasons. 3uT threshold clears the ~0.5-1uT
sensor noise while still catching household sources. Earth's constant
25-65uT background is explicitly what the rolling baseline exists to
subtract.

Both are additive: neither replaces the existing motion-based EMF, which
stays the fallback because it works on iOS where neither of these do
(no Web Bluetooth, no Generic Sensor API in any iOS browser). Both reuse
the time-aware EMA baseline shape from coldSpot.ts, since advertisement
and sensor intervals are irregular and a fixed per-sample alpha would
weight a burst and a long gap identically.

Web Bluetooth types are declared locally rather than pulling in
@types/web-bluetooth for three shapes — same approach lib/emf.ts already
takes with its non-standard sensor types.

Also renders unprompted 'manifest' utterances as an intrusion: violet edge,
full opacity (unlike the faded ambient/fragment murmurs), and a brief
blur-in. The unsettling part is that it is perfectly clear and completely
unbidden.

355 frontend tests pass (26 new); i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:46:48 +00:00
Indiana
bbcbaa0a36 feat: shared spectrum scope with audible anomalies, from real sources only
Replaces two ad-hoc canvases (a 256x110 fixed-size waterfall in the radio
panel, a bar-graph in the EVP panel) with one source-agnostic SpectrumScope
that renders any binned dB spectrum, plus sonification so anomalies can be
heard rather than watched.

Sources are real measured data only. The RTL-SDR path is genuine RF; the
EVP path is a genuine AnalyserNode FFT of the device microphone. The ESP32
deliberately does NOT feed this: its firmware reports four scalars
(temperature, pressure, evp level, presence) and has no spectrum at all,
and device_anomaly.frequency_for_sensor_type() invents a per-sensor-type
frequency for the fiction — neither is a real spectrum, so neither is
plotted as one.

SpectrumScope draws three layers because each answers a different question:
the live trace (what is happening now), a decaying peak-hold (what was
strongest recently, so a transient survives a glance away), and a waterfall
(what the last minute looked like, where a steady carrier separates from a
one-off burst). Anomaly markers flare at their frequency and fade over
~2.6s, so a spike already gone from the trace still says where to look.

Frames reach the scope through a ref, not a prop. Routing 60Hz frames
through React state re-renders the panel and the scope on every frame on
top of the rAF loop that actually draws — measurably the wrong call on a
phone. The EVP producer double-buffers into two fixed Float64Arrays so a
frame costs zero allocation.

spectrumSonify maps band position to pitch exponentially, so equal
fractions of the band are equal musical intervals (a linear Hz map crams
the bottom half into one indistinguishable octave), and magnitude to
gain via sqrt so faint hits stay audible. Pings are throttled to 90ms
because a busy band otherwise smears into a buzz that conveys nothing.
Every entry point no-ops rather than throwing when audio is unavailable —
a dead speaker must never take down the scope drawing the data.

Audio requires an explicit gesture (ListenToggle), because iOS keeps any
context created outside a touch handler permanently suspended.

Also exposes EvpListener.sampleRate/nyquistHz and labels the EVP axis from
the real hardware rate. The old code assumed 48kHz; Bluetooth headsets and
some Android inputs hand back 44.1k or 16k, which mislabelled the spectrum
by nearly an octave.

Mobile: DPR-aware canvases, ResizeObserver, 44px touch targets,
touch-action so dragging the scope pans the page, reduced-motion honoured,
crowded axis ticks dropped under 560px.

329 frontend tests pass (18 new); i18n en/es parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 00:26:54 +00:00
Indiana
f8ca4bbd9e fix: unbounded essence farming, WS crash, and two economy races
Unbounded essence/item farming: every other reward trigger (summon,
question, fragment) had both a per-user and per-IP limiter, but
ritual_start and judgment had none at all — and judgment has no
"already resolved" state either. A scripted client could replay
{"type":"judgment","verdict":"cross_over"} in a tight loop and mint
CROSS_OVER_ESSENCE (25) plus a 20% item roll every iteration, forever.
Same for ritual_start -> 4x ritual_step. Added ritual/judgment limiters
in both flavors, matching the existing pattern.

WS session crash: _handle_question did `if state.entity is None:
await _handle_summon(state)` then `assert state.entity is not None`.
_handle_summon returns early *without* setting state.entity when the
seeker is rate-limited, so the assert fired unhandled — and the message
loop only catches WebSocketDisconnect, so it killed the whole connection.
Reachable with no malice: click summon a few times impatiently, then ask a
question. Now returns cleanly (the rate_limited frame was already sent).

Essence double-spend: purchase_unlock() deliberately uses SELECT ... FOR
UPDATE to serialize concurrent purchases, but the three credit_essence
call sites in ws.py did an unlocked db.get() read-modify-write. An
unlocked read doesn't block on a row lock, so a reward computed from a
pre-purchase balance could be written after the purchase committed,
silently reverting the deduction — user keeps the unlock and the essence.
All three now lock the row the same way.

Entity mint collision: _summon does a racy check-then-insert against
Entity.signature and Entity.name, both DB-unique, with no IntegrityError
handling — a concurrent mint of the same signature crashed the session.
Forceable by a user with two accounts (anomaly frequency/magnitude are
client-controlled), and plausible without malice in wire mode, where
sample_network() reads host-wide /proc/net/dev counters so two idle
sessions genuinely measure the same traffic. Now retries once, which
re-runs the match against whatever the winner committed.

Also added a unique constraint on unlocks(user_id, unlock_key) as
defense-in-depth, with an idempotent catalog-guarded migration.

221 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:30:21 +00:00
Indiana
eaa299f683 fix: stale-frame race, stuck verdict buttons, and 3 more frontend defects
seance.tsx / JudgmentPanel.tsx: neither `ritual_complete` nor
`judgment_result` carries an entity id, and both were applied
unconditionally — so a response still in flight when the seeker summoned a
fresh entity landed on whatever entity happened to be current when it
arrived, leaking the *previous* entity's hidden traits into the new one's
revealed-traits UI. Both frames are now gated on our still waiting for one
(ritual.status === 'in_progress' / judgmentPending), which the 'entity'
case clears the moment a new presence arrives, so a late answer for the old
entity is dropped instead of misattributed.

JudgmentPanel also had `pending` in component-local state that only cleared
when judgmentResult became a *new* truthy object. If the entity changed
while judgmentResult was already null, the reset was a no-op (null === null)
and pending stayed stuck, permanently disabling all four verdict buttons.
It now reads the shared judgmentPending flag, which the reducer resets.

coldSpot.ts: severity was ungated by `warm` while isColdSpot/
isPressureAnomaly were correctly gated. ColdSpotPanel feeds severity
straight into the composite disturbance gauge with no boolean gate of its
own, so a freshly-paired device could show "disturbance rising" off its 2nd
reading — exactly what the minSamples warm-up exists to prevent.

PlanchetteBoard.tsx: the first GOODBYE deadline was a bare
randomBetween(120,300) compared against `t`, which is seconds since
performance.timeOrigin (page load), not since mount. Every later reschedule
correctly offsets from `t`. On a tab open >5min before the board mounted
(or any remount via navigation), t was already past the deadline and the
planchette snapped to GOODBYE on the first frame.

sdr.ts: close() and setFrequency() inside the sweep loop were not wrapped in
withTimeout despite the file's own header claiming every stalling USB call
is. A dongle going unresponsive mid-sweep or during teardown hung forever —
the same silent-hang symptom withTimeout was added to eliminate.

InventoryPanel.tsx: essence was decremented client-side using a possibly
stale fallback price and never reconciled. The server already returns the
real post-purchase balance in PurchaseOut; use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:47:39 +00:00
Indiana
31f3f91801 fix: four real firmware defects found in adversarial review
rd03e.c: RD03E_FRAME_LEN was 5 but the frame's own documented layout
(header + gesture + distance_lo + distance_hi + footer[2]) is 6 bytes.
The footer check read buf[i+3], colliding with the distance high byte at
that same index — so every frame that validated at all was forced to have
distance_cm = lo | 0x5500 (~218m) regardless of what the sensor reported.
Distance readings were garbage 100% of the time, not intermittently.

mems_mic.c: i2s_del_channel() was missing on 2 of 3 init failure paths,
leaking the channel handle.

bmp280.c: the I2C bus/device handles leaked on 4 of 5 init failure paths;
added a fail label that releases both.

app_main.c: sensors now init before Wi-Fi bring-up, matching the rationale
sensor_driver.h already documents (a hanging sensor bus must not be able to
block network bring-up).

rtlsdr_experimental.c: rtlsdr_exp_stop() waited 500ms before
usb_host_uninstall(), but the daemon task blocks up to 1000ms inside
usb_host_lib_handle_events() before re-checking its running flag — the
delay must exceed that or teardown races a live daemon task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:47:21 +00:00
Indiana
825f6aa510 fix: RTL-SDR bulk reads could hang forever with zero user feedback
Real bug report: user selects their RTL-SDR dongle in the WebUSB picker,
"nothing happens" — no error, no sweep, no visible change at all.

Root cause: WebUSB's transferIn/controlTransfer calls have no built-in
timeout. readSamples()'s bulk transferIn (called every sweep step, twice —
once to discard PLL-settle samples, once for real) had nothing bounding
it, so if the dongle doesn't actually stream data for any reason (this
init sequence has never been verified against real hardware), that
promise just never settles — indistinguishable from the page being frozen,
forever, with no way for the UI to ever surface an error.

Added withTimeout(), applied to: the bulk IQ read (4s — the one most
likely to actually hang during sweeping) and the whole open()/init
sequence as one unit (15s, since it's ~20 sequential unverified register
pokes). Also stopped silently falling back to default
interface/endpoint values when the device's USB descriptor doesn't expose
a bulk-IN endpoint as expected — now logs a warning so a real endpoint
mismatch is at least visible in DevTools instead of only surfacing as a
downstream hang.

4 new unit tests for withTimeout(). 306/306 frontend tests pass.
2026-07-25 18:38:59 +00:00
Indiana
53239a923d feat: planchette wander reaches YES/NO, periodically rests on GOODBYE
Wander bounds were tied to the letter ring's radius, which sits inside
YES/NO's corner positions — widened to derive bounds directly from the
board's actual outermost fixed waypoints (YES/NO for the top/sides, the
number row for the bottom) so idle drift covers the whole interactive
board, corners included.

Added a periodic deliberate visit to GOODBYE: every 2-5 minutes
(randomized so multiple open tabs don't sync up), the planchette glides
down and rests there for 3-5 seconds with the same restrained glow used
for ambiently-brushed letters, then resumes normal wander. Purely a
visual beat — no session/game state changes, distinct from an actual
goodbye action.
2026-07-25 17:47:59 +00:00
Indiana
a4103818d3 fix: planchette idle wander was confined to a tiny central zone
The wander amplitude (w*0.16, h*0.14) was a fixed fraction of the canvas
with no relationship to the outer letter ring's actual radius
(min(w*0.4, h*0.52) — set in computeLayout's arc() calls), so idle drift
could never reach anywhere near the outer letters (A/M/N/Z, the arc tops).
Tied the wander radius directly to that same ring radius/squash factor
instead, so it genuinely roams the whole board.

Also added an ambient "letter brushing" effect: while idly wandering (not
actively spelling a real reply), a letter the planchette drifts near gets
a faint glow — visibly dimmer than the bright hover/dwell glow used for
real spelled letters, so a passing brush never reads as the spirit
actually saying something. Purely cosmetic, no game-state changes.
2026-07-25 17:35:42 +00:00
Indiana
5325a7c782 feat: ground the mint prompt in specific, mundane human detail
The old prompt asked for "an evocative spirit name" and "2-3 sentences of
lore" — abstract enough that the LLM defaulted to poetic ghost-vagueness
(static, voids, ancient sorrow) rather than anything resembling a specific
dead person. Horror fiction's actual technique for selling "this was once
a real human" is the opposite: mundane, unglamorous specificity (an
ordinary job, an approximate age/decade, one small habit or possession)
placed right up against the uncanny.

Refined MINT_SYSTEM and MINT_PROMPT to require the model silently work out
a name, occupation, age/era of death, one small mundane detail, and a
plain (not epic) unfinished-business hook before writing persona/quotes —
and to make at least one quote a mundane human fragment rather than
cosmic riddle-speak. JSON schema and mint_prompt()'s signature are
unchanged, so nothing downstream needs updating — this is a pure prompt
refinement. 20/20 prompt/entity tests pass, 221/221 full suite.
2026-07-25 15:14:08 +00:00
Indiana
5e29add272 Merge Cold Spot Detector: atmospheric anomaly visualization for DevicesPage 2026-07-25 00:24:53 +00:00
Indiana
917cd7f89e feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay
Real-time anomaly visualization for temperature/pressure readings on the
device-feed dashboard, folklore's two most iconic paranormal markers:
sudden cold spots and rapid barometric swings.

- lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker
  (time-aware EMA, since hardware doesn't report on a fixed schedule),
  cold-spot and pressure-anomaly classifiers, and a composite
  Atmospheric Disturbance Index that rewards correlated anomalies
  (a lone signal caps at 50/100; only both deviating together can
  reach 100) — modeled on evilMeter.ts's threaded-state pattern.
- components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for
  temperature, ripple treatment for pressure, and a crescent-arc
  composite gauge (GhostLog's evil-meter gauge as the visual family
  reference) that only appears once a device has reported both sensors.
- DevicesPage.tsx: owns per-device baseline state, feeds it from
  `reading` frames, falls back to the existing generic row for any
  non-numeric temperature/pressure value.

26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation,
correlated-vs-solo index, gappy/out-of-order data) and 7 new
DevicesPage integration tests. Full suite: 302/302 passing, tsc clean,
i18n coverage clean (en/es).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:00:35 +00:00
Indiana
f09e077199 firmware: add I2S MEMS microphone driver; fix missing essence migration
Mic: confirmed via its pinout (L/R, WS, SCK, SD, VCC, GND) that the third
target module is a standard I2S digital MEMS mic (INMP441-family). Added
mems_mic.c/h using ESP-IDF's current driver/i2s_std.h API — reports RMS
audio level in dBFS as sensor_type "evp" rather than attempting on-device
voice-band FFT (the browser EVP mode's approach); the backend's existing
statistical anomaly detector handles spike detection from the raw level,
same as it already does for temperature/pressure/presence.

Also fixes a real gap Workstream B's report flagged: User.essence (a live
model column used throughout merged code — /auth/me, inventory purchases,
summon trickle) had no migration line in main.py's lifespan, which would
have broken on the actual production Postgres database.
2026-07-24 21:52:03 +00:00
Indiana
7ebedf363b Merge Workstream B: ritual + judgment + favor + cross-over 2026-07-24 21:30:42 +00:00
Indiana
6e627bae15 firmware: verify against actual target hardware, fix real conflicts
Researched the exact modules the user is building with and fixed three
concrete issues the earlier speculative firmware got wrong:

1. WiFi: confirmed the target board (Waveshare ESP32-P4-Module-DEV-KIT,
   chip ESP32-P4NRW32) bridges WiFi through an onboard ESP32-C6
   co-processor over a fixed 7-pin SDIO link (CLK18/CMD19/D0-14/D1-15/
   D2-16/D3-17/RESET54, cross-confirmed against Espressif's own
   esp-hosted-mcu docs). wifi_manager.c's esp_wifi_init()/esp_wifi_start()
   calls don't need to change — esp_wifi_remote/esp_hosted provide a
   drop-in-compatible API — but the component manifest (new
   main/idf_component.yml) and sdkconfig.defaults were missing entirely.

2. Real pin conflict: the presence sensor's original UART pins (17/18)
   directly collided with the SDIO CLK/D3 pins above — wiring it there
   would have broken WiFi, the sensor, or both. Moved to GPIO4/5.

3. Swapped placeholder parts for the user's actual hardware:
   - BME280 -> BMP280 (GY-BMP280 module): temp+pressure only, no humidity.
     Rewrote the driver rather than just renaming it — the old code would
     have read nonexistent humidity registers and reported garbage
     forever. 3.3V-only wiring note added (the BME280 assumption of
     5V-tolerant logic doesn't hold for this specific breakout).
   - LD2410 -> RD-03E (Ai-Thinker, not Hi-Link — a different manufacturer
     with a different, incompatible UART protocol). Rewrote the frame
     parser against the RD-03E's actual (if less-documented) 5-byte
     simple-report format. Reports numeric distance instead of a boolean,
     which better fits both the hardware's actual output and the backend's
     statistical anomaly detector.

README, CMakeLists.txt, and all cross-references updated to match.
2026-07-24 21:30:37 +00:00
Indiana
36c4a9e6e4 feat: ritual + judgment + favor + cross-over (Workstream B)
Implements Workstream B of the character-depth-ghost-log spec:
ritual_start/ritual_step/judgment WS handlers, the pure judgment.py
logic module, and the User.favor / Entity.at_peace columns + migration.

- app/judgment.py: pure ritual success roll (base 65%, floored at 30%,
  driven by an entity's power+deceptiveness difficulty), the "stuck
  spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20%
  of entities), judgment correctness/favor-delta/essence-delta/
  consequence resolution for all four verdicts, favor clamping, the
  favor-to-trait-roll bias applied at mint time, and tell-line
  generation (opaque behavioral flavor text, never a raw stat).
- app/ws.py: wires ritual_start/ritual_step/judgment frames, emits
  ritual_complete/tell/judgment_result/item_drop per the spec's
  Contract; traits are added to serialize_entity for internal
  server-side use but stripped from the outbound `entity` frame via a
  new _public_entity helper so hidden ground truth never reaches the
  client outside ritual_complete; _summon excludes at-peace entities
  from signature re-contact and mints a fresh (salted-signature) entity
  instead; new entities' traits are nudged by the discovering user's
  favor before being persisted.
- models/user.py, models/entity.py, main.py: User.favor and
  Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT
  EXISTS migration lines in lifespan, alongside the existing ones.
- tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new
  tests covering the ritual/judgment correctness matrix, favor
  clamping/bias, essence crediting, at_peace persistence + re-contact,
  and the entity-frame trait leak guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:27:30 +00:00
Indiana
f87e9ab3f5 Merge Workstream J: RTL-SDR experimental firmware module
Resolved add/add conflict in the top-level firmware README: both I and J
created one (J's brief said "create it if I hasn't", and both ran in
isolated worktrees with no visibility into each other). Combined them —
kept I's comprehensive core-project README as the base, appended J's real
RTL-SDR technical section, dropped J's now-stale "Status of this
directory" preamble (written when it couldn't see I's already-completed
work) and its duplicate honesty-policy note. Updated the stale
components/README.md placeholder to reflect that the module now exists.
2026-07-24 20:49:31 +00:00
Indiana
267dfe82cd Merge Workstream I: ESP32-P4 firmware core sensor node 2026-07-24 20:48:02 +00:00
Indiana
58b1ac397c Merge Workstream H: device pairing UI + live dashboard
Resolved conflicts in App.tsx/SeancePage.tsx (both F and H added new
routes/nav links, kept both) and i18n files (both added sibling top-level
keys — inventory + devices, fixed nesting after conflict markers removed).

Also fixed a real integration gap: DevicesPage.test.tsx's mock User object
predated Workstream F's unlocks/essence additions to the User type (the
two workstreams built in parallel isolation and couldn't see each other's
changes). 269/269 frontend tests pass, tsc clean.
2026-07-24 20:47:57 +00:00
Indiana
2f4832a3a8 Wire Workstream K's summon-pipeline integration into G's ingestion handoff
Both G and K were built independently against the paper contract and
correctly left this connection point for the integrator (documented in
both their reports). Calls process_device_reading_for_summon from
_process_reading, skipping array-valued readings (no defined single
scalar baseline for those). Adds an end-to-end integration test that
connects a real /ws/session, posts device telemetry through it, and
confirms an anomalous reading actually reaches the live session as an
anomaly_ack — proving the two independently-built pieces genuinely
connect, not just that each compiles.

165/165 backend tests pass.
2026-07-24 20:30:42 +00:00
Indiana
f023b591b5 Merge Workstream K: hardware anomalies feed the summon pipeline 2026-07-24 14:57:14 +00:00
Indiana
ec3bdc086e Merge Workstream G: device pairing + ingestion + live broadcast
Resolved conflict in main.py: combined both workstreams' router imports
and registrations (device_router from G, inventory_router from C).
143/143 backend tests pass after cleaning stray pollution from an
earlier parallel workstream run against the shared test DB.
2026-07-24 14:57:10 +00:00
Indiana
f633408a16 Merge Workstream C: unlocks + inventory items + sigils + essence 2026-07-24 09:41:55 +00:00
Indiana
30c0da1eaf Merge Workstream F: unlocks/inventory UI + sigil designer + listening tool 2026-07-24 09:39:19 +00:00
Indiana
98864976b1 Merge Workstream E: ritual mini-game + judgment UI + consequences
Resolved conflict in types.ts: dropped the duplicate EntityTraits
definition (D and E both added it identically) and combined both
workstreams' new ServerFrame variants (tell/ritual_complete from D,
judgment_result from E). 223/223 frontend tests pass, tsc clean.
2026-07-24 09:39:08 +00:00
Indiana
1b4fc8b1eb Merge Workstream D: Ghost Log HUD + evil meter + tells 2026-07-24 03:56:58 +00:00
Indiana
ca979a1800 Merge Workstream A: entity hidden traits + migration 2026-07-24 03:56:48 +00:00
Indiana
e9546d9637 chore: gitignore .claude/worktrees/ (agent isolation worktrees) 2026-07-24 03:56:44 +00:00
Indiana
ff68379772 feat: unlocks, inventory items, sigils, drops, and essence (Workstream C)
Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:

- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
  Sigil (sigils) — brand-new tables, picked up by main.py's existing
  create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
  essence economy constants, sigil design validation, and an atomic
  (row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
  (validates the placeholder {points, rune} shape, points capped at 12),
  POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
  key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
  point that exists in this worktree today (_handle_summon, covering
  every successful summon plus high-rarity summons); the other two
  contract trigger points (correct judgment, successful ritual) belong
  to Workstream B's not-yet-landed ritual/judgment WS handlers, which
  should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
  added here per orchestrator instruction so this workstream is
  independently testable — merge controller reconciles the duplicate
  edit).

Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.

Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 03:02:20 +00:00
Indiana
004f66171f feat: hardware sensor anomalies feed the summon pipeline (Workstream K)
Adds backend/app/device_anomaly.py — per-(user_id, device_id, sensor_type)
rolling-baseline anomaly detection for continuous numeric sensors
(structurally modeled on telemetry.detect_wire_spike: min samples, an
absolute floor, 3-sigma + relative threshold, with per-sensor-type floors
since units vary wildly) plus a false->true state-transition detector for
discrete/boolean sensors like presence.

Adds a module-level active-session registry in app/ws.py
(register_active_session/unregister_active_session/get_active_session)
so hardware ingestion (a plain HTTP call, not a WS connection) can find a
user's live SeanceState.

process_device_reading_for_summon(user_id, device_id, sensor_type, value,
unit) is the self-contained entry point Workstream G's ingestion handler
will call into: classifies numeric vs. boolean, runs the reading through
the right detector, and on a genuine anomaly pushes it into the active
session via the existing _handle_anomaly path (source=sensor_type,
frequency=stable per-sensor-type constant, magnitude=deviation-from-
baseline or a fixed constant for boolean transitions) — reusing the full
existing signature/mint/Codex pipeline, no new mint logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:14:11 +00:00
Indiana
348b5fc778 feat(firmware): ESP32-P4 sensor node — Workstream I core skeleton
New firmware/esp32p4-sensor-node/ ESP-IDF (C, FreeRTOS) project skeleton
per docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md's
Workstream I:

- Wi-Fi station-mode connect with exponential-backoff reconnect
  (wifi_manager.c), credentials from a gitignored main/device_config.h
  the seeker fills in (template: device_config.h.example).
- Telemetry HTTP client (telemetry_client.c) POSTing the spec's exact
  contract shape to /api/device/telemetry with a Bearer token, via
  esp_http_client + cJSON.
- BME280 I2C driver (bme280.c) with Bosch's public double-precision
  compensation formulas, using ESP-IDF's newer driver/i2c_master.h API.
- LD2410 mmWave presence driver (ld2410.c) over UART, chosen over a
  plain PIR for its distance/motion data richness — its frame-offset
  parsing is flagged as the least-certain code in the firmware.
- sensor_driver_t registry (sensor_driver.h, sensor_registry.c) so new
  sensors are a new driver file + one array line, no main-loop changes.
- README.md: build steps, manual-config walkthrough, wiring/pinouts,
  and an explicit "what's verified vs. not" section plus a real
  hardware caveat (ESP32-P4 has no integrated Wi-Fi radio).

UNVERIFIED AGAINST REAL HARDWARE per the spec's honesty-policy note —
no ESP-IDF toolchain or physical boards available in this environment.
Syntax-checked with gcc against hand-written ESP-IDF API stubs (not
committed) as a best-effort substitute for a real idf.py build.

Workstream J (RTL-SDR experimental module) is explicitly out of scope
here; firmware/esp32p4-sensor-node/components/ is left in place for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:13:44 +00:00
Indiana
c88fbc843a feat: device pairing, telemetry ingestion, live dashboard WS (Workstream G)
Implements the backend half of the ESP32-P4 sensor node spec's pairing,
ingestion, and live-broadcast contract:

- New Device model (backend/app/models/device.py): id, user_id FK, name,
  token_hash (unique+indexed), created_at, last_seen_at. Reuses
  generate_session_token()/hash_token() from auth_session.py verbatim for
  the one-time raw pairing token / stored hash.
- POST /api/device, GET /api/device (session-cookie authenticated REST
  pairing endpoints) and POST /api/device/telemetry (device bearer-token
  authenticated ingestion, per-device rate limited, 16KB body cap, 64
  reading cap, strict shape validation — never a 500 on garbage input) in
  backend/app/routes/device.py.
- /ws/device-feed live dashboard WS (qm_session cookie authenticated),
  fanning out ingested readings to the owning user's connected dashboard
  sockets via an in-process dict[user_id, connections] registry, each with
  its own send-queue + single sender task (mirrors app.ws's
  SeanceState/_sender convention).
- last_seen_at updates on every successful ingestion.
- _process_reading(device, reading) left as an explicit no-op handoff point
  for Workstream K's summon-pipeline integration.

Backend suite: 102 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:12:21 +00:00
Indiana
e886c94d40 feat(frontend): device pairing UI + live telemetry dashboard (Workstream H)
Adds /devices — pair an ESP32 sensor node (POST /api/device), reveal its
raw pairing token exactly once with a hard-to-miss "cannot be shown again"
warning (styled like a real API-key-reveal UI), then a live dashboard
subscribing to /ws/device-feed: the initial `devices` frame seeds paired
devices, and `reading` frames update one row per distinct sensor_type in
place. sensor_type/value/unit are rendered fully generically per the
contract (free-form, open-ended) — an unrecognized sensor_type renders
safely with no special-casing.

- frontend/src/lib/deviceFeed.ts: reconnecting WS client for
  /ws/device-feed, mirroring VeilSocket's backoff shape (receive-only, no
  outbox needed).
- frontend/src/pages/DevicesPage.{tsx,css}: pairing form + one-time token
  reveal + live device-card grid. Leans into "hacker" terminal styling
  (monospace readouts, terminal device cards) over the app's usual gothic
  chrome, per the design spec, while keeping the existing dark/violet
  palette tokens from App.css.
- Route + nav link wired into App.tsx / SeancePage.tsx.
- i18n: new `devices.*` / `nav.devices` keys in en.json + es.json; added a
  coverage-check.mjs domain rule for the dynamic connection-state key,
  mirroring the existing `seance.connection.` rule.

Tests: deviceFeed.test.ts (backoff/reconnect/frame delivery) and
DevicesPage.test.tsx (empty state, name validation, one-time token reveal
and dismissal, live frame updates in place without duplicating rows,
multi-device/multi-sensor rendering, and a mocked unrecognized sensor_type
that must not crash). Full suite: 154 passed (137 pre-existing + 17 new).
`npx tsc -b` and `npm run build` both clean.

Assumption (undocumented in spec): POST /api/device's JSON response shape
is inferred as `{id, name, token, last_seen_at}` since the Contract section
only describes the endpoint in prose. GET /api/device is intentionally not
called — the live dashboard is fully seeded by /ws/device-feed's initial
`devices` frame per the contract, so it's redundant for this page's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:10:24 +00:00
Indiana
abd7174c9c feat(firmware): add experimental RTL-SDR USB-host module (Workstream J)
Self-contained ESP-IDF component (firmware/esp32p4-sensor-node/components/
rtlsdr_experimental/) exploring RTL2832U-over-USB-host on the ESP32-P4,
ported from frontend/src/lib/sdr.ts's researched WebUSB protocol sequence
(vendor commands, I2C-repeater tuner init) to the ESP-IDF USB Host Library.

Implements: USB Host Library install/client lifecycle, RTL2832U/Terratec
vendor-ID device matching, the demod+R820T init vendor-command sequence
over control transfers, a pipelined bulk-IN read loop for raw IQ, and an
inert-by-default upstream IQ-forwarding stub targeting a proposed separate
binary endpoint (not the JSON telemetry shape — reasoning documented in
the README) since no such backend endpoint exists yet.

Off by default (RTLSDR_EXP_ENABLE Kconfig, default n). Unverified against
real hardware and never compiled (no ESP-IDF toolchain in this
environment) — marked as such in every source file and in a dedicated
"Workstream J" section of firmware/esp32p4-sensor-node/README.md, which
this commit also creates since Workstream I's core skeleton (owned by a
separate, unmerged worktree) hadn't created one yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:10:21 +00:00
Indiana
cf817e5241 Add ESP32-P4 Sensor Node design spec
Third sub-project: physical hardware (ESP32-P4, presence + BME280 env
sensors, experimental RTL-SDR USB-host module) pairs with a user's account
and streams telemetry that feeds the SAME anomaly/summon pipeline the
browser-based modes already use — not a passive dashboard, the actual
business model (selling devices that summon spirits). Defines device
pairing/auth (reusing the existing session-token hash convention),
a generic/extensible sensor-reading shape, and the live-broadcast +
anomaly-detection contract split across 5 workstreams (G/K backend,
H frontend, I/J firmware).
2026-07-23 18:28:10 +00:00