From requirements to running code. This site captures every design decision, contract, and architectural choice — the single source of truth before a line of implementation code is written.
A cross-platform mobile app that locates lost skis using BLE body-blocking direction finding.
Custom "Ski Spot V1.0.0" PCB by Southern Composite Engineering. ESP32-class BLE transmitter mounted on each ski.
React Native (Expo bare workflow). Cross-platform Android + iOS. TypeScript strict mode throughout.
BLE body-blocking: user rotates 360°, app finds lowest RSSI bearing, points arrow in opposite direction toward lost ski.
Start Scan
User presses START, begins slow 360° rotation
Collect Samples
App records RSSI + compass heading at ~10 Hz simultaneously
Find Minimum
Bucket into 5° bins, smooth, find weakest signal bearing (body blocking)
Point Arrow
Arrow points opposite to minimum = toward the ski. Updates live with compass.
Seven behavioral lenses applied to every design decision. The lenses inform the design. The design informs the build.
Solve for psychological reality, not logical assumptions.
A user who lost a ski is stressed and cold. "Scanning..." with no feedback feels broken. A polar chart filling in real-time reframes waiting as progress.
Show live polar plot during rotation, not a spinner. The act of rotating becomes purposeful rather than uncertain.
Map friction across discovery, cognitive, execution, emotional, and protective dimensions.
User shouldn't need to understand RSSI or BLE. Arrow points at ski. That's it.
One-tap START. No settings to configure before scanning. Default device auto-selected if only one paired.
Calibration prompt only when magnetometer accuracy is actually low, not every time.
B = MAP. Motivation + Ability + Prompt must converge.
High — user has lost an expensive ski. No motivation design needed.
Must work with gloves on. Large tap targets. No typing required during scan flow.
Clear instruction overlay: "Hold phone at chest height and turn slowly in a circle."
Universal quality patterns. Design for user goals, not tasks.
System status always visible: signal strength bar, compass heading readout, scan progress arc.
"Incomplete rotation" warning with clear retry — not a dead end. Scan data preserved for partial results.
Automation spectrum: from suggestion to autonomous decision.
Show confidence indicator with result. Strong null = high confidence arrow. Weak null = "Try moving closer" suggestion.
Polar chart shows the raw data behind the arrow. User can visually verify the algorithm's decision.
Second-order effects and feedback loops.
BLE scanning drains phone battery. Cold weather reduces it further. Show battery warning if below 20%.
ESP32 transmitter has finite battery. If no signal found, guide user: "Is the Ski Spot turned on? Battery may be depleted."
Functional → Reliable → Usable → Pleasurable.
Success state: satisfying animation when ski is found. Haptic feedback on result. The moment of relief should feel earned.
Weak signal state: "Getting warmer..." as RSSI improves on approach. Gamify the walk toward the ski.
Three-layer architecture: Surface (interface), Core (logic), Edge (hardware).
src/
app/ # Navigation + screens
(tabs)/
FindScreen.tsx # Main scan/rotate/result
DevicesScreen.tsx # Paired device management
SettingsScreen.tsx
_layout.tsx
components/ # Reusable UI
DirectionArrow.tsx # Animated compass arrow
RSSIPolarChart.tsx # Real-time polar plot
ScanButton.tsx # START/STOP toggle
DeviceCard.tsx # BLE device list item
SignalStrengthBar.tsx
core/ # Business logic
algorithm.ts # Bearing calculation
ble.ts # BLE scanning + RSSI
compass.ts # Magnetometer heading
types.ts # Shared interfaces
stores/ # Zustand state
scanStore.ts # Scan state + samples
deviceStore.ts # Paired devices
utils/ # Pure functions
angles.ts # Circular angle math
filters.ts # Signal smoothing
constants.ts # UUIDs, timing, thresholds
Machine-readable specifications that bridge design intent and implementation. No code is written until contracts are approved.
Click any contract to expand the specification.
These types form the contract between all modules. Every function signature references these.
interface RSSISample {
heading: number; // 0-360 degrees from magnetic north
rssi: number; // dBm, typically -30 to -100
timestamp: number; // ms since epoch
}
interface BinnedRSSI {
centreHeading: number; // Centre of the 5-degree bin
avgRSSI: number; // Mean RSSI in this bin
sampleCount: number; // Number of samples in bin
}
interface ScanResult {
deviceBearing: number; // Computed direction to device
confidence: number; // 0-1 based on null depth
coverage: number; // Fraction of bins covered (0-1)
bins: BinnedRSSI[]; // For polar chart display
rawSamples: RSSISample[]; // Full dataset
}
interface PairedDevice {
id: string; // BLE device ID (platform-specific)
name: string; // User-assigned name ("Left Ski")
hardwareName: string; // Original BLE advertised name
lastSeen: number; // Timestamp of last detection
lastRSSI: number; // Most recent signal strength
}
type ScanPhase = 'idle' | 'scanning' | 'computing' | 'result';
interface AlgorithmConfig {
binSizeDegrees: number; // Default: 5
smoothingWindow: number; // Default: 3 bins
emaAlpha: number; // Default: 0.3
coverageThreshold: number; // Default: 0.85
outlierThresholdDb: number; // Default: 20
}
Every UI component, its props, and parent-child relationships.
App
├── NavigationContainer
│ └── BottomTabs
│ ├── FindScreen
│ │ ├── DeviceSelector props: { devices, selected, onSelect }
│ │ ├── ScanButton props: { phase, onStart, onStop }
│ │ ├── RSSIPolarChart props: { bins, currentHeading }
│ │ ├── DirectionArrow props: { bearing, compassHeading }
│ │ ├── SignalStrengthBar props: { rssi, min, max }
│ │ ├── CoverageArc props: { coverage, threshold }
│ │ └── ScanInstructions props: { phase }
│ ├── DevicesScreen
│ │ ├── DeviceCard[] props: { device, onRename, onRemove }
│ │ ├── AddDeviceButton props: { onPress }
│ │ └── ScanModal props: { visible, found[], onSelect }
│ └── SettingsScreen
│ ├── FirmwareInfo props: { version }
│ └── ResetButton props: { onReset }
Two stores: scan state (ephemeral) and device state (persisted).
// ─── scanStore.ts ───
interface ScanStore {
// State
phase: ScanPhase;
targetDeviceId: string | null;
samples: RSSISample[];
currentHeading: number;
currentRSSI: number | null;
result: ScanResult | null;
error: string | null;
// Actions
startScan: (deviceId: string) => void;
stopScan: () => void;
addSample: (sample: RSSISample) => void;
updateHeading: (heading: number) => void;
updateRSSI: (rssi: number) => void;
reset: () => void;
}
// ─── deviceStore.ts ───
interface DeviceStore {
// State (persisted to AsyncStorage)
pairedDevices: PairedDevice[];
selectedDeviceId: string | null;
// Actions
addDevice: (device: PairedDevice) => void;
removeDevice: (id: string) => void;
renameDevice: (id: string, name: string) => void;
selectDevice: (id: string) => void;
updateLastSeen: (id: string, rssi: number) => void;
resetAll: () => void;
}
How data moves through the system during a scan cycle.
BLE Advertisement Received
ble.ts → scanStore.updateRSSI(rssi)
Magnetometer Reading
compass.ts → scanStore.updateHeading(heading)
Sample Created (merged at ~10Hz tick)
scanStore.addSample({ heading, rssi, timestamp })
EMA Applied Per-Sample
filters.ts → exponentialMovingAverage(rssi, α=0.3)
User Stops Scan
scanStore.stopScan() → algorithm.calculateDeviceBearing(samples)
Bucket → Smooth → Find Minimum → Invert
algorithm.ts → ScanResult { deviceBearing, confidence, bins }
Result Rendered
DirectionArrow rotation = deviceBearing - currentCompassHeading
Required permissions and platform-specific behaviour.
| Permission | iOS | Android | Notes |
|---|---|---|---|
| Bluetooth | NSBluetoothAlwaysUsageDescription | BLUETOOTH_SCAN + BLUETOOTH_CONNECT | Android 12+ requires both |
| Location | Not required for BLE | ACCESS_FINE_LOCATION | Android requires for BLE scanning |
| Motion | NSMotionUsageDescription | None required | Magnetometer access |
| Background | Foreground only | Foreground only | Scan requires active user |
Three-phase cadence: Concept → Contract → Cut. We are currently in the Contract phase.
Requirements decomposition, architecture specification, stack selection.
Formal blueprints as reviewable specifications. No code until approved.
AI-driven code generation against approved contracts. Operator review at each milestone.
Open questions and architectural decisions that must be resolved before the Cut phase. Add notes to refine the final build.
The actual service UUID from the Ski Spot V1.0.0 firmware must be obtained via nRF Connect scan or firmware source code.
Target 10-15 seconds for 360°. Too fast = sparse data, too slow = RSSI drift. Needs field testing to calibrate.
Scan for both skis simultaneously or one at a time? Simultaneous is better UX but doubles processing and may confuse the polar chart.
At what RSSI threshold is the body-blocking null too faint to detect? Estimated around -85 dBm. Below this, results are unreliable.
Wet clothing may change attenuation pattern. Buried ski under snow may have different BLE propagation characteristics.
Must be fast enough for ~10 Hz sampling. Configure firmware to advertise every 50-100 ms. Faster = more battery drain on transmitter.
How to quantify result confidence? Options: null depth (dB difference between min and max RSSI), null sharpness (angular width of the dip), or combined metric.
Opinionated defaults to reduce decision overhead. Each choice has a rationale.
Single codebase, native BLE access via bare workflow
Mature lib, supports RSSI scanning without connection
Raw magnetometer at high sample rate
Lightweight, no boilerplate, works with RN
Stack + bottom tabs, standard RN navigation
Persist paired device list across sessions
No any. Type safety across BLE data structures
Unit tests for algorithm, component tests for UI
Custom PCB by Southern Composite Engineering