ADHD Habit Enforcement Watch & Anchor — Firmware Specification¶
Version: 0.1 (Draft) Scope: Firmware for the ESP32-C3-WROOM Watch and ESP32-C3-WROOM Anchor devices, intended as a complete reference for a coding agent.
Table of Contents¶
- Hardware Reference
- System Overview
- Shared Data Structures
- Anchor Firmware
- Watch Firmware - 5.1 State Machine - 5.2 Worn Detection - 5.3 Schedule System - 5.4 Enforcement - 5.5 Anchor Communication - 5.6 BLE GATT Service (Watch ↔ Phone)
- Inter-Device Communication Protocols
- Constants & Tunables
1. Hardware Reference¶
Watch (ESP32-C3-WROOM)¶
| Function | Pin | Notes |
|---|---|---|
| Active buzzer | GPIO 12 | HIGH = on |
| SPI MISO | GPIO 2 | |
| SPI MOSI | GPIO 7 | |
| SPI CLK | GPIO 6 | |
| IMU chip select | GPIO 8 | |
| IMU interrupt INT1 | GPIO 4 | Configured as motion wakeup source |
| Capacitive touch out | GPIO 1 | HIGH = touched/worn |
| Vibration motor | GPIO 10 | HIGH = on |
| Battery voltage sense | GPIO 3 | ADC, Vbat/2 (non-functional, reserved for future) |
IMU: LIS3DHTR, connected via SPI. Touch sensor: PT2041AT6, output on GPIO 1.
Anchor (ESP32-C3-WROOM)¶
| Function | Pin | Notes |
|---|---|---|
| Active buzzer | GPIO 12 | HIGH = on |
| SG90 servo signal | GPIO 10 | PWM; 180° = closed/locked, 160° = open |
The anchor uses the ESP32-C3's built-in WiFi and BLE radios.
2. System Overview¶
The system consists of two device types:
Anchors are fixed devices placed at meaningful locations around a home. They advertise their identity over BLE and receive commands over WiFi. They hold a copy of the daily schedule and beep when instructed.
Watches are wearable devices. They enforce a user-defined schedule of location and WiFi presence requirements by vibrating and/or buzzing when the user is not complying. They communicate with a companion mobile app over BLE and WiFi, and with anchors over WiFi.
3. Shared Data Structures¶
These data structures are used by both the watch firmware and the anchor firmware. The encoding used when transmitting over the wire is defined in Section 6.
3.1 Enumerations¶
enum RecurrenceType {
once = 0,
daily = 1,
weekly = 2,
monthly = 3,
}
// Criteria defines what physical condition the watch enforces.
enum Criteria {
getAway = 0, // User must be away from anchorId
stayNear = 1, // User must be near anchorId
getOffWifi = 2, // User must disconnect from wifiSSID
getOnWifi = 3, // User must connect to wifiSSID
}
// EnforcementProfile defines the watch's behavior during enforcement.
// All profiles are described fully in Section 5.4.
enum EnforcementProfile {
strictSilent = 0,
normalSilent = 1,
looseSilent = 2,
strictBoth = 3,
normalBoth = 4,
looseBoth = 5,
strictBuzz = 6,
normalBuzz = 7,
looseBuzz = 8,
}
// AnchorEnforcementProfile defines the anchor beeping pattern when the watch
// is removed during an enforcement event.
enum AnchorEnforcementProfile {
light = 0, // beep 3s, wait 60s, repeat
medium = 1, // beep 3s, wait 30s, repeat
hard = 2, // beep 4s, wait 10s, repeat
}
3.2 Event Struct¶
struct Event {
String id; // UUID v4, 16 bytes
int64 referenceDate; // Unix timestamp (seconds). For 'once': the specific
// date. For recurring: the origin/anchor date from
// which recurrence is calculated.
uint16 startTime; // Minutes since midnight (0–1439)
uint16 endTime; // Minutes since midnight (0–1439). Must be > startTime.
RecurrenceType recurrenceType;
uint8 dayOfWeek; // 1–7 (Mon–Sun). Required if weekly; 0 otherwise.
uint8 dayOfMonth; // 1–31. Required if monthly; 0 otherwise.
Criteria criteria;
EnforcementProfile profile;
bool negate; // If true, this event cancels a recurring event for
// one specific day. See Section 5.3.
String? anchorId; // UUID of target anchor. Null if WiFi-based criteria.
String? wifiSSID; // Target WiFi network name. Null if anchor-based criteria.
// Anchor beep-on-removal fields:
List<String> beepAnchors; // UUIDs of anchors that should beep if the watch is
// removed during this event. May be empty.
AnchorEnforcementProfile? anchorProfile; // Null if beepAnchors is empty.
}
Validation rules (enforced at the app level before transmission; the firmware may assert but not correct):
- If recurrenceType == weekly, dayOfWeek must be 1–7.
- If recurrenceType == monthly, dayOfMonth must be 1–31.
- If recurrenceType == once or daily, dayOfWeek and dayOfMonth must be 0.
- Exactly one of anchorId or wifiSSID must be non-null, except when negate == true, in which case both may be null.
- If beepAnchors is non-empty, anchorProfile must be non-null.
- endTime > startTime (events may not span midnight).
3.3 Anchor Device Record¶
Used by the watch to track known anchors:
struct AnchorRecord {
String uuid; // UUID v4
String name; // Human-readable name, assigned in mobile app
int8 lastRSSI; // Last observed RSSI in dBm
int64 lastSeen; // Unix timestamp of last BLE advertisement seen
String? ipAddress; // Last known IPv4 address, null if never seen on WiFi
int64 ipLastUpdated; // Unix timestamp when ipAddress was last confirmed by app
}
4. Anchor Firmware¶
4.1 Overview¶
The anchor is a minimal device that: 1. Advertises its UUID over BLE continuously. 2. Accepts BLE connections for initial setup (WiFi credentials, identify command). 3. Connects to WiFi and listens for UDP commands from the watch. 4. Holds a daily schedule copy and beeps when appropriate. 5. Manages an SG90 servo on GPIO 10 that mechanically locks or releases the anchor's strap, preventing the anchor from being moved during an enforcement event.
4.2 Identity & First Boot¶
On first boot, the anchor checks NVS (non-volatile storage) for an existing UUID. If none is found (factory-fresh state), it generates a random UUID v4, stores it to NVS, and uses it permanently. The UUID never changes unless a factory reset is performed.
Factory reset procedure: Hold the factory reset trigger (implementation detail: a GPIO pad or button defined in hardware config) for 5 continuous seconds. The firmware erases the NVS partition and reboots. On next boot, a new UUID is generated.
4.3 BLE Advertising¶
The anchor advertises continuously using the iBeacon format:
- UUID: The anchor's stored UUID.
- Major: 0x4A0F — fixed Impulse namespace fingerprint. The watch uses this value to filter out non-Impulse iBeacons (AirTags, Tile trackers, etc.) during BLE scanning.
- Minor: 0x0000 (unused).
- Advertising interval: 100ms.
- Connectable: Yes. The anchor must remain connectable at all times to support setup and identify operations.
The anchor also emits a scan response packet containing:
- The ANCHOR_SERVICE_UUID as a service UUID, allowing the mobile app to identify anchors by service UUID on platforms (iOS) where Apple manufacturer data is stripped from scan results.
- The anchor's 16-byte UUID as service data under ANCHOR_SERVICE_UUID, so the app can read the anchor's identity from the scan response without connecting.
4.4 Anchor GATT Service¶
The anchor exposes one custom BLE GATT service for setup operations.
Service UUID: ANCHOR_SERVICE_UUID (a fixed constant defined in the shared codebase, used by both anchor firmware and the mobile app).
Characteristic: Identify (Write, No Response)¶
UUID: ANCHOR_IDENTIFY_CHAR_UUID
On any write (payload is ignored), the anchor activates its buzzer for IDENTIFY_BEEP_DURATION_MS (default: 800ms), then stops. This allows the mobile app user to identify which physical anchor corresponds to which UUID.
Characteristic: WiFi Credentials (Write, With Response)¶
UUID: ANCHOR_WIFI_CRED_CHAR_UUID
Payload (JSON, UTF-8):
On write: the anchor stores the credentials to NVS and attempts to connect to the given WiFi network. It responds with:
- 0x01 on successful connection.
- 0x00 on failure (connection timeout or wrong credentials).
The anchor stores only one WiFi credential pair. Writing new credentials overwrites the old ones.
Characteristic: Anchor Settings (Write, With Response)¶
UUID: ANCHOR_SETTINGS_CHAR_UUID
Payload (binary, little-endian):
max_beep_minutes is the maximum number of minutes the anchor will beep continuously for a single watch-removed event, regardless of event end time. Default: 30. This is a device-level setting, not per-event.
On write: stores setting to NVS, responds with 0x01.
Characteristic: Schedule Transfer Control (Write, With Response)¶
UUID: ANCHOR_SCHED_CTRL_CHAR_UUID
See Section 6.2 for the full schedule transfer protocol. The anchor uses the same protocol as the watch.
Characteristic: Schedule Transfer Data (Write, No Response)¶
UUID: ANCHOR_SCHED_DATA_CHAR_UUID
See Section 6.2.
Characteristic: Toggle (Read + Write, With Response)¶
UUID: ANCHOR_TOGGLE_CHAR_UUID
Controls the SG90 servo that mechanically locks or releases the anchor's strap. See Section 4.9 for full servo management behavior.
Write payload (1 byte):
- 0x00 — Close (lock): rotate servo to SERVO_CLOSED_DEGREES (180°).
- 0x01 — Open (unlock): rotate servo to SERVO_OPEN_DEGREES (160°).
Read payload (1 byte):
- 0x00 — Servo is currently in the closed/locked position.
- 0x01 — Servo is currently in the open/unlocked position.
Response codes (returned after write):
- 0x01 — Command accepted and executed.
- 0x02 — Command rejected: an enforcement event is currently active that involves this anchor (see Section 4.9 for the exact check). Only open commands can be rejected; close commands are always accepted.
4.5 WiFi Operation¶
After successfully connecting to WiFi, the anchor:
1. Obtains an IP address via DHCP.
2. Registers itself on the local network via mDNS as <UUID>.local for discoverability.
3. Opens a UDP socket on port ANCHOR_UDP_PORT (defined constant) and listens for commands.
If WiFi disconnects, the anchor continues BLE advertising and attempts reconnection every ANCHOR_WIFI_RETRY_INTERVAL_S seconds using stored credentials.
4.6 UDP Command Handling¶
All UDP datagrams received on ANCHOR_UDP_PORT are parsed as follows:
Packet format (binary, little-endian):
Commands:
- 0x01 — WATCH_REMOVED: The watch has been taken off during an enforcement event.
- 0x02 — WATCH_WORN: The watch has been put back on.
On WATCH_REMOVED:
1. Look up the current event from the internal daily schedule (see Section 4.7).
2. Check whether this anchor's UUID appears in the event's beepAnchors list.
3. If yes, begin the anchor enforcement beeping pattern defined by the event's anchorProfile.
4. Continue beeping until:
- A WATCH_WORN command is received for the same event UUID, OR
- The event's endTime is reached (per the internal schedule), OR
- max_beep_minutes of continuous beeping has elapsed.
5. If this anchor's UUID is not in beepAnchors, ignore the command.
On WATCH_WORN: 1. Stop all beeping immediately.
4.7 Anchor Schedule Management¶
The anchor stores the current day's schedule in RAM after receiving it from the mobile app. The schedule is a sorted list of Event structs for today, computed identically to the watch (see Section 5.3). The anchor uses the schedule only to:
- Determine whether it should respond to a WATCH_REMOVED command (by checking if its UUID is in beepAnchors of the current event).
- Know when to stop beeping (event endTime).
The schedule is pushed by the mobile app over WiFi using an HTTP POST to http://<anchor-ip>/schedule. See Section 6.2 for the schedule encoding.
The app pushes the schedule to all known anchor IPs unconditionally — it does not wait for or check whether any anchor is online before sending. If an anchor is offline at push time, it simply misses the update. The app pushes the schedule in the following situations: - Once daily at midnight (to all known anchors). - Whenever the user modifies the schedule in the app (to all known anchors). - Whenever a new anchor IP is added to the app's anchor table.
Every day at midnight, the anchor clears its current schedule and waits for the app's push. If no schedule has been received for the current day by the time an event window would begin, the anchor treats beepAnchors as empty for all events and will not beep.
4.8 Anchor Beeping Patterns¶
Beeping is driven by the anchorProfile field of the active event:
| Profile | Pattern |
|---|---|
| light | Buzzer ON 3s, OFF 60s, repeat |
| medium | Buzzer ON 3s, OFF 30s, repeat |
| hard | Buzzer ON 4s, OFF 10s, repeat |
Beeping stops under the conditions listed in Section 4.6.
4.9 Servo Management¶
The anchor uses an SG90 servo on GPIO 10 to mechanically lock or release its strap.
Default State¶
On boot, the servo is commanded to the closed position (SERVO_CLOSED_DEGREES = 180°), then detached. The internally tracked servo state is initialized to closed.
ServoManager Behavior¶
The servo must never actively hold a position. The pattern for every command is:
- Attach the servo (begin sending PWM on GPIO 10).
- Write the target angle (
SERVO_CLOSED_DEGREESorSERVO_OPEN_DEGREES). - Wait
SERVO_MOVE_DURATION_MSfor the servo to physically reach the position. - Detach the servo (stop PWM output on GPIO 10).
- Update internal servo state to
openorclosedaccordingly.
Enforcement Check¶
An enforcement event is considered active and involving this anchor if the current local time falls within any event's [startTime, endTime) window in the day's schedule, AND at least one of the following is true:
- This anchor's UUID matches event.anchorId.
- This anchor's UUID appears in event.beepAnchors.
Toggle Command Logic¶
function handle_toggle_command(value: uint8) -> uint8:
if value == 0x00: // close
servo_manager_command(SERVO_CLOSED_DEGREES)
return 0x01 // always accepted
else if value == 0x01: // open
if anchor_is_in_active_enforcement_event():
return 0x02 // rejected
servo_manager_command(SERVO_OPEN_DEGREES)
return 0x01 // accepted
Auto-Close on Enforcement Start¶
When the anchor's schedule determines that an enforcement event has begun (i.e., the current time has entered a [startTime, endTime) window for an event that involves this anchor):
- If the internal servo state is open, immediately issue a close command via servo_manager_command(SERVO_CLOSED_DEGREES).
- This auto-close bypasses the enforcement check and always executes.
The anchor evaluates enforcement boundaries by checking the schedule at the start of each event window. Because the anchor does not have an RTC-callback system as sophisticated as the watch, it checks whether any event boundary has been crossed on each schedule update push and on each Toggle write. For time-accurate auto-close, the app is responsible for ensuring the anchor receives the schedule before events begin.
5. Watch Firmware¶
5.1 State Machine¶
5.1.1 Connectivity Flags¶
The watch maintains two independent boolean flags at all times:
bt_connected— true when the companion phone app has an active BLE connection.wifi_connected— true when the watch has an active WiFi connection.
These flags are updated immediately on connect/disconnect events and are independent of the activity state.
The named connectivity states from the product design map to flag combinations as follows (for documentation and app status reporting only):
| Name | bt_connected | wifi_connected |
|---|---|---|
| wild | true | false |
| away | false | true |
| disconnected | false | false |
Settings governing connectivity behavior:
| Setting | Default | Effect |
|---|---|---|
DISCONNECTED_IS_DORMANT |
true | If true, the watch enforces schedule normally when both flags are false |
AWAY_IS_DORMANT |
true | If true, the watch enforces schedule normally when only wifi is true |
When a connectivity flag becomes true and enforcement was previously suspended due to it being false, immediately run recalculate_day(today) and re-arm RTC callbacks.
5.1.2 Activity States¶
The watch has three activity states:
UNPAIRED
Factory default. The watch has never been paired with a phone.
Behavior: - Does not read from IMU. - Touch sensor has no effect. - No schedule enforcement. - Only waits for an incoming BLE connection request.
Transitions:
- → DORMANT on first successful BLE connection. On transition: emit a short beep (UNPAIRED_BEEP_DURATION_MS) and a short vibration (UNPAIRED_VIBRATE_DURATION_MS).
This state is permanent until factory reset once any BLE connection has ever been made.
DORMANT
The watch is ready to enforce but no event is currently active. This is the nominal resting state.
Behavior:
- Periodically scans for BLE advertisements from known and unknown anchors (interval: BLE_SCAN_INTERVAL_S). An advertisement is only accepted as an Impulse anchor if it is a valid iBeacon and its Major field equals 0x4A0F (the Impulse namespace fingerprint). Advertisements failing this check are silently ignored, filtering out third-party iBeacons such as AirTags.
- On discovering an unknown anchor UUID, adds it to the seen-anchors list and notifies the app via the Seen Anchors GATT characteristic.
- Periodically scans for known WiFi networks (interval: WIFI_SCAN_INTERVAL_S). On finding a known network, attempts connection.
- Processes incoming BLE GATT writes from the app (schedule updates, settings, etc.).
- When it has completed all pending work, transitions to DORMANT_SLEEP.
- Motion interrupt re-arm delay: After processing an IMU interrupt, the firmware does not immediately re-arm the interrupt. Instead it waits MINIMUM_BLE_DELAY_DORMANT milliseconds before clearing the LIS3DH INT1 latch (which allows the next motion event to fire the ISR). While INT1 is latched HIGH no new rising edge is generated, so the interrupt is effectively muted for this period. This prevents rapid continuous movement from repeatedly resetting the DORMANT_TO_SLEEP_IDLE_MS timer and blocking the transition to DORMANT_SLEEP.
Transitions:
- → DORMANT_SLEEP when no pending callbacks within DORMANT_TO_SLEEP_IDLE_MS and IMU reports no significant motion.
- → ENFORCEMENT when an RTC callback fires for an event start time.
DORMANT_SLEEP
The watch is in ESP32 light sleep mode to conserve battery. Before entering this state, the firmware sets an RTC wakeup timer for the next event boundary (start or end time), or for midnight if no more events remain today.
Wakeup sources (any of these transitions to DORMANT): - RTC timer fires. - IMU motion interrupt fires on GPIO 4. - Incoming BLE packet received.
ENFORCEMENT
An event's time window is active and the watch is monitoring and enforcing its criteria.
Behavior:
- Polls the enforcement condition every ENFORCEMENT_POLL_INTERVAL_S seconds.
- Additionally checks the condition immediately on IMU motion interrupt (GPIO 4), as significant movement may indicate the user has complied.
- Motion interrupt re-arm delay: After processing an IMU interrupt, the firmware waits MINIMUM_BLE_DELAY_ENFORCEMENT milliseconds before re-arming the interrupt (clearing the LIS3DH INT1 latch). This limits how often motion can trigger a fresh BLE scan during normal activity, preventing battery drain from continuous movement that has no bearing on the enforcement criteria.
- If condition is NOT met: activates the event's EnforcementProfile pattern (Section 5.4).
- If condition IS met: ceases all motor/buzzer output immediately. If the user has also been still for ENFORCEMENT_IDLE_BEFORE_SLEEP_MS, enters enforcement light sleep (Section 5.4.3).
- Monitors the touch sensor. If the watch is determined to be not worn (Section 5.2), sends WATCH_REMOVED UDP datagrams to all anchors in beepAnchors. When worn again, sends WATCH_WORN.
Transitions:
- → DORMANT when the RTC callback fires for the event's endTime.
5.1.3 Boot-Time Recovery¶
On every boot:
1. Load stored schedule from flash.
2. Load stored settings from NVS.
3. Determine current time from RTC (if available) or request time sync from phone/NTP.
4. Call recalculate_day(today).
5. Find whether the current time falls within any event's [startTime, endTime) window.
- If yes: enter ENFORCEMENT immediately for that event.
- If no: enter DORMANT, arm RTC callback for the next upcoming event boundary.
- If no more events remain today: enter DORMANT_SLEEP until midnight.
5.1.4 Mid-Day Schedule Update¶
When the app pushes a new schedule while the watch is active:
1. Deserialize and validate the new schedule.
2. Store to flash, replacing the previous schedule.
3. Call recalculate_day(today).
4. Cancel all existing RTC callbacks.
5. Re-arm RTC callbacks for all future event boundaries in the updated schedule. Boundaries whose time has already passed today are skipped.
6. Check whether current time falls in an active event window and enter or exit ENFORCEMENT accordingly.
5.1.5 Midnight Initialization¶
Every day at midnight (00:00:00 local time), regardless of current state:
1. Call recalculate_day(new date).
2. Cancel all existing RTC callbacks.
3. Arm RTC callbacks for every startTime and endTime boundary in today's event list.
4. Arm an RTC callback for the following midnight.
5.2 Worn Detection¶
The watch determines whether it is being worn using the capacitive touch sensor output on GPIO 1.
Constants (all values must be defined as named constants for easy tuning)¶
TOUCH_WORN_THRESHOLD_RAW // Raw ADC/digital value above which the sensor reports contact.
// Exact meaning depends on PT2041AT6 output mode; tune empirically.
TOUCH_DEBOUNCE_SAMPLES // Number of consecutive consistent readings required to change
// worn state. Default: 5.
TOUCH_SAMPLE_INTERVAL_MS // Time between sensor samples during debounce. Default: 1000ms.
// At 5 samples × 1000ms, this gives a 5-second debounce.
TOUCH_CONFIRM_WORN_LEVEL // The digital level (HIGH or LOW) that the PT2041AT6 outputs
// when the sensor is touched. Set per hardware testing.
Logic¶
The worn state is a boolean, initially assumed false on boot.
function sample_worn_state() -> bool:
reading = read_gpio(TOUCH_GPIO)
return reading == TOUCH_CONFIRM_WORN_LEVEL
function update_worn_state():
// This function is called on a timer every TOUCH_SAMPLE_INTERVAL_MS.
// It maintains a rolling buffer of the last TOUCH_DEBOUNCE_SAMPLES readings.
append sample_worn_state() to rolling_buffer
if all values in rolling_buffer are the same:
new_worn = rolling_buffer[0]
if new_worn != current_worn_state:
current_worn_state = new_worn
on_worn_state_changed(new_worn)
function on_worn_state_changed(is_worn: bool):
if is_worn:
// Watch put on: send WATCH_WORN to relevant anchors if in enforcement
if activity_state == ENFORCEMENT and active_event.beepAnchors is not empty:
send_watch_worn_to_anchors(active_event)
else:
// Watch taken off: send WATCH_REMOVED to relevant anchors if in enforcement
if activity_state == ENFORCEMENT and active_event.beepAnchors is not empty:
send_watch_removed_to_anchors(active_event)
Note: The debounce applies symmetrically to both the removal and re-engagement transitions. A 5-second sustained reading change is required in both directions before the worn state changes.
5.3 Schedule System¶
5.3.1 Storage¶
The schedule is stored in two separate structures in flash/NVS:
recurring_events: A list ofEventstructs whererecurrenceType != once.specific_events: A map from date (YYYY-MM-DD string key) to a list ofEventstructs whererecurrenceType == once.
Both are written atomically when a schedule push is received from the app.
5.3.2 recalculate_day(date) -> List\<Event>¶
This function computes the full ordered event list for a given date. It is the single source of truth for what events are active on any day.
function recalculate_day(date: Date) -> List<Event>:
// Step 1: Get recurring events that apply to this date
recurring = get_recurring_events(date)
// Step 2: Get specific (one-time) events for this date
specific = specific_events[date] ?? []
// Step 3: Merge. Specific events override recurring events with the same UUID.
merged = {} // map from UUID to Event
for event in recurring:
merged[event.id] = event
for event in specific:
merged[event.id] = event // overwrites if UUID matches
// Step 4: Remove negated events
result = [event for event in merged.values() if not event.negate]
// Step 5: Sort by startTime ascending
result.sort(by: event.startTime)
return result
5.3.3 get_recurring_events(date) -> List\<Event>¶
function get_recurring_events(date: Date) -> List<Event>:
result = []
for event in recurring_events:
if event.recurrenceType == daily:
result.append(event)
else if event.recurrenceType == weekly:
if day_of_week(date) == event.dayOfWeek:
result.append(event)
else if event.recurrenceType == monthly:
if day_of_month(date) == event.dayOfMonth:
result.append(event)
return result
5.3.4 Negate Mechanic¶
A specific (one-time) event with negate = true and a UUID matching a recurring event causes that recurring event to be deleted for that one day only. The negating event itself is also removed (Step 4 above). The net result is the recurring event simply does not appear in that day's schedule.
5.3.5 Timezone¶
The watch stores a timezone_offset_minutes value (int16, e.g. -300 for UTC-5). All startTime and endTime fields in Event structs are in local time (minutes since local midnight). All Unix timestamps in the schedule are UTC. The watch converts between UTC and local time using timezone_offset_minutes when computing RTC wakeup targets and when determining the current local time.
The timezone value is pushed by the mobile app as part of the Settings characteristic write (Section 5.6).
5.4 Enforcement¶
5.4.1 Condition Checking¶
During ENFORCEMENT state, the watch checks whether the active event's criteria is currently satisfied.
Anchor-based criteria (getAway, stayNear):
- Scan BLE for advertisements from the anchor UUID specified in
event.anchorId. - Measure RSSI of the most recent advertisement from that anchor.
- Compare to
ANCHOR_NEAR_RSSI_THRESHOLD_DBM(tunable constant, default:-70 dBm). - Ifrssi >= ANCHOR_NEAR_RSSI_THRESHOLD_DBM→ watch is NEAR the anchor. - Ifrssi < ANCHOR_NEAR_RSSI_THRESHOLD_DBMor anchor not seen inANCHOR_SEEN_TIMEOUT_Sseconds → watch is AWAY from the anchor. - For
stayNear: condition is met if watch is NEAR. - For
getAway: condition is met if watch is AWAY.
WiFi-based criteria (getOnWifi, getOffWifi):
- Check current WiFi connection status.
- For
getOnWifi: condition is met ifwifi_connected == trueand current SSID matchesevent.wifiSSID. - For
getOffWifi: condition is met ifwifi_connected == falseor current SSID does not matchevent.wifiSSID.
Polling:
- The condition is checked on a timer every
ENFORCEMENT_POLL_INTERVAL_Sseconds (default: 30). - The condition is also checked immediately when the IMU motion interrupt fires on GPIO 4, as significant user movement may mean the condition has just been satisfied.
- When the condition transitions from not-met to met: all motor/buzzer output stops immediately.
- When the condition transitions from met to not-met: the event's enforcement profile begins (or resumes) from its beginning.
BLE scan strategy during enforcement:
BLE scanning during ENFORCEMENT is driven by motion, not by a fixed timer, to avoid running the radio while the user is stationary.
- WiFi-based events (
getOnWifi,getOffWifi): No BLE scanning is performed. The enforcement condition is determined entirely from the WiFi connection state and requires no anchor RSSI data. - Anchor-based events (
getAway,stayNear): - When the IMU motion interrupt fires, a BLE scan is started immediately before the condition check, so that
check_enforcement_condition()operates on fresh RSSI data. - A fallback timer (
ENFORCEMENT_BLE_FALLBACK_SCAN_INTERVAL_S, default: 300 s) ensures a scan is still performed periodically even when the user is completely still, guarding against cases where the anchor itself has moved or the RSSI cache has expired. BLE_SCAN_INTERVAL_Sapplies only in DORMANT state; it is not used during ENFORCEMENT.- All enforcement BLE scans use a bounded duration of
ENFORCEMENT_SCAN_DURATION_MSmilliseconds (default: 300 ms). Since anchors advertise every 100 ms, this provides 3 advertisement windows — sufficient to reliably detect any in-range anchor. DORMANT scans continue to use duration 0 (indefinite) for thorough anchor discovery. - Enforcement BLE scans use a reduced duty cycle: scan interval
ENFORCEMENT_SCAN_INTERVAL_MS(default: 1000 ms) with active windowENFORCEMENT_SCAN_WINDOW_MS(default: 100 ms), giving a ~10% duty cycle. DORMANT uses 200 ms / 180 ms (90%). The scanner is reconfigured to DORMANT settings on exit from ENFORCEMENT. Any in-progress scan is stopped before reconfiguring.
WiFi during enforcement:
WiFi behavior during ENFORCEMENT depends on the event's criteria type, to eliminate concurrent BLE + WiFi radio contention that causes current spikes at low battery.
- Anchor-based events (
getAway,stayNear): If WiFi is connected when enforcement begins, the watch disconnects WiFi and records that it did so (wifi_suspended_for_enforcement = true). The WiFi radio remains off for the duration of enforcement. Ifsend_to_anchors()needs to fire (worn-state change), the watch reconnects on-demand before sending, using the saved credentials. - WiFi-based events (
getOnWifi,getOffWifi): WiFi is kept connected since the enforcement condition depends on it, but the radio is put into modem sleep (WIFI_PS_MAX_MODEM) to reduce current draw during idle periods. - On exit from ENFORCEMENT: if
wifi_suspended_for_enforcementis set, a reconnection attempt is made before returning to DORMANT.
5.4.2 Enforcement Light Sleep¶
When the enforcement condition is met and the user has been still long enough, the watch enters a light sleep to conserve battery. The watch remains in STATE_ENFORCEMENT throughout — this is not a transition to DORMANT_SLEEP.
Entry conditions (all must be true):
- condition_met == true (no buzzer/motor output needed)
- data_ready == false (no pending IMU interrupt)
- No BLE scan currently in progress
- (millis() - last_activity_ms) > ENFORCEMENT_IDLE_BEFORE_SLEEP_MS
Sleep duration: ENFORCEMENT_POLL_INTERVAL_S seconds, capped at the number of seconds remaining until the event's endTime, so the event never ends late.
Wakeup sources: identical to DORMANT_SLEEP — RTC timer, IMU motion on GPIO 4, and BLE connection if ADVERTISE_DURING_SLEEP is enabled.
On wakeup:
1. Re-arm the IMU ISR.
2. Reset last_enf_poll_ms to force an immediate condition re-check on the next loop iteration.
3. For anchor-based events: start a bounded BLE scan (ENFORCEMENT_SCAN_DURATION_MS) so the upcoming condition check has current RSSI data.
4. Resume normal enforcement loop.
5.4.3 Enforcement Profiles¶
Enforcement profiles define the pattern of motor and/or buzzer output while the condition is not met. Each profile is a state machine that loops until the condition is met or the event ends.
Profiles are implemented as a data-driven table. Adding a new profile requires only adding a new entry to this table — no new control-flow code.
Profile table:
struct ProfileStep {
bool motor_on;
bool buzzer_on;
uint32 duration_ms;
}
struct ProfileDefinition {
EnforcementProfile id;
bool loops;
List<ProfileStep> steps;
uint32? floor_interval_ms; // If not null, the wait step shrinks by
// INTERVAL_DECREMENT_MS each cycle until
// it reaches floor_interval_ms.
}
const INTERVAL_DECREMENT_MS = 2000; // 2 seconds, applies to all escalating profiles
Strict Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: CONTINUOUS }
]
loops: false // single continuous step
Normal Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 }, // wait, decrements by 2s each cycle
]
loops: true
floor_interval_ms: 5000 // wait step floors at 5 seconds
Loose Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 }, // wait, decrements by 2s each cycle
]
loops: true
floor_interval_ms: 10000 // wait step floors at 10 seconds
Strict Both:
Normal Both:
steps: [
{ motor_on: true, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 5000
Loose Both:
steps: [
{ motor_on: true, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 10000
Strict Buzz:
Normal Buzz:
steps: [
{ motor_on: false, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 5000
Loose Buzz:
steps: [
{ motor_on: false, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 10000
Escalating wait step logic (for all looping profiles):
Each time through the loop, the wait step's duration is reduced by INTERVAL_DECREMENT_MS. This reduction accumulates across cycles. When the wait duration would fall below floor_interval_ms, it is clamped to floor_interval_ms and stays there.
Example for Normal Silent: - Cycle 1: vibrate 2s, wait 30s - Cycle 2: vibrate 2s, wait 28s - Cycle 3: vibrate 2s, wait 26s - ... - Cycle 14: vibrate 2s, wait 5s - Cycle 15+: vibrate 2s, wait 5s (floored)
5.5 Anchor Communication (Watch Side)¶
5.5.1 Sending Watch-Removed / Watch-Worn¶
When on_worn_state_changed fires during ENFORCEMENT for an event with non-empty beepAnchors:
function send_watch_removed_to_anchors(event: Event):
command = WATCH_REMOVED (0x01)
send_to_anchors(command, event)
function send_watch_worn_to_anchors(event: Event):
command = WATCH_WORN (0x02)
send_to_anchors(command, event)
function send_to_anchors(command: uint8, event: Event):
payload = encode_udp_command(command, watch_uuid, event.id)
for anchor_uuid in event.beepAnchors:
record = anchor_records[anchor_uuid]
success = false
// Attempt 1: direct IP (from app-provided AnchorRecord)
if record.ipAddress != null:
success = udp_send(record.ipAddress, ANCHOR_UDP_PORT, payload,
retries: UDP_RETRY_COUNT,
timeout_ms: UDP_RETRY_TIMEOUT_MS)
// Attempt 2: mDNS fallback
if not success:
ip = mdns_resolve(anchor_uuid + ".local",
timeout_ms: MDNS_RESOLVE_TIMEOUT_MS)
if ip != null:
success = udp_send(ip, ANCHOR_UDP_PORT, payload,
retries: UDP_RETRY_COUNT,
timeout_ms: UDP_RETRY_TIMEOUT_MS)
// Attempt 3: UDP broadcast
if not success:
udp_send("255.255.255.255", ANCHOR_UDP_PORT, payload, retries: 1)
// Broadcast is best-effort; no success tracking
// If still unreachable after direct IP and mDNS attempts, queue notification
if not success:
queue_unreachable_notification(anchor_uuid, record.name)
5.5.2 Unreachable Anchor Notifications¶
When an anchor cannot be reached, the watch queues a notification:
The next time bt_connected == true, the watch sends all queued notifications to the app via the Watch Status GATT characteristic (see Section 5.6).
5.6 BLE GATT Service (Watch ↔ Phone)¶
The watch exposes one custom GATT service.
Service UUID: WATCH_SERVICE_UUID (fixed constant, shared between firmware and app).
Characteristic: WiFi Credentials (Write, No Response)¶
UUID: WATCH_WIFI_CRED_CHAR_UUID
Payload (JSON, UTF-8):
On write:
1. Attempt WiFi connection.
2. On success: save credentials to NVS credentials list, set wifi_connected = true.
3. On failure: save credentials to NVS credentials list anyway (for future auto-connect attempts).
4. Notify app of result via Watch Status characteristic.
The watch maintains a list of saved WiFi credential pairs in NVS. It attempts to connect to any of them when WiFi scanning finds a matching network.
Characteristics: Schedule Transfer Control & Data¶
Control UUID: WATCH_SCHED_CTRL_CHAR_UUID (Write, With Response)
Data UUID: WATCH_SCHED_DATA_CHAR_UUID (Write, No Response)
See Section 6.2 for the full schedule transfer protocol.
Characteristic: Settings (Write, With Response)¶
UUID: WATCH_SETTINGS_CHAR_UUID
Payload (binary, little-endian):
[1 byte: DISCONNECTED_IS_DORMANT (0 or 1)]
[1 byte: AWAY_IS_DORMANT (0 or 1)]
[2 bytes: timezone_offset_minutes (int16, signed, e.g. -300 for UTC-5)]
On write: stores all settings to NVS, responds with 0x01. If a timezone change occurs, re-arm all RTC callbacks using the new offset.
Characteristic: Seen Anchors (Read + Notify)¶
UUID: WATCH_SEEN_ANCHORS_CHAR_UUID
Payload (binary, little-endian):
[1 byte: anchor count N]
for each anchor (N entries):
[16 bytes: UUID]
[1 byte: last RSSI, encoded as (rssi + 128) to make it unsigned]
[4 bytes: last_seen Unix timestamp (uint32)]
The watch sends a BLE notification on this characteristic whenever a new anchor UUID is discovered. The app can also read this characteristic at any time to get the current full list.
Characteristic: Watch Status (Notify + Read)¶
UUID: WATCH_STATUS_CHAR_UUID
The watch pushes a notification any time any field in this payload changes.
Payload (binary, little-endian):
[1 byte: activity_state (0=dormant, 1=enforcement, 2=dormant_sleep)]
[1 byte: bt_connected (0 or 1)]
[1 byte: wifi_connected (0 or 1)]
[1 byte: worn (0 or 1)]
[1 byte: battery_pct (0–100; 0xFF if not yet available)]
[16 bytes: active_event_id (UUID of current enforcement event; zero-filled if none)]
[1 byte: unreachable_anchor_count N]
for each unreachable notification (N entries):
[16 bytes: anchor UUID]
[1 byte: anchor name length L]
[L bytes: anchor name (UTF-8)]
[4 bytes: timestamp (uint32 Unix)]
After delivering queued unreachable notifications, the watch clears them from its queue.
Characteristic: Anchor IP Table (Write, With Response)¶
UUID: WATCH_ANCHOR_IP_CHAR_UUID
The app writes the current known IP addresses of all anchors to this characteristic. This is how the watch learns anchor IPs without relying solely on mDNS.
Payload (binary, little-endian):
[1 byte: entry count N]
for each entry (N entries):
[16 bytes: anchor UUID]
[4 bytes: IPv4 address (uint32, network byte order)]
[4 bytes: timestamp of IP confirmation (uint32 Unix)]
On write: updates the watch's AnchorRecord table entries. Responds with 0x01.
6. Inter-Device Communication Protocols¶
6.1 UDP Command Packet (Watch → Anchor)¶
All UDP messages from the watch to anchors use this fixed 33-byte format:
[1 byte: command]
0x01 = WATCH_REMOVED
0x02 = WATCH_WORN
[16 bytes: watch UUID]
[16 bytes: event UUID]
No response is expected from the anchor. The watch uses the retry logic defined in Section 5.5.1.
6.2 Schedule Transfer Protocol¶
Used for both BLE (watch) and HTTP (anchor). The schedule is encoded as a binary blob and transferred using a 3-phase protocol.
Phase 1: BEGIN¶
Write to the control characteristic / HTTP endpoint with:
Phase 2: DATA¶
Write sequential chunks to the data characteristic. Each chunk is at most BLE_MTU_BYTES - 3 bytes (to fit BLE ATT packet). For HTTP, the body is the full blob in one request.
Schedule binary encoding (little-endian):
[2 bytes: event count N (uint16)]
for each Event (N entries):
[16 bytes: UUID (raw bytes)]
[8 bytes: referenceDate (int64 Unix timestamp)]
[2 bytes: startTime (uint16, minutes since midnight)]
[2 bytes: endTime (uint16, minutes since midnight)]
[1 byte: recurrenceType]
[1 byte: dayOfWeek (0 if unused)]
[1 byte: dayOfMonth (0 if unused)]
[1 byte: criteria]
[1 byte: enforcementProfile]
[1 byte: anchorProfile (0xFF if null)]
[1 byte: negate (0 or 1)]
[1 byte: anchorId presence flag (0=absent, 1=present)]
[16 bytes: anchorId UUID (zero-filled if absent)]
[1 byte: wifiSSID length L (0 if absent)]
[L bytes: wifiSSID (UTF-8, omitted if L=0)]
[1 byte: beepAnchors count M]
for each beepAnchor (M entries):
[16 bytes: anchor UUID]
Phase 3: END¶
Write to the control characteristic with:
On receiving END:
- Compute CRC32 of the received buffer.
- If matches: deserialize, store to flash, run recalculate_day(today), re-arm callbacks. Respond 0x01.
- If mismatch: discard buffer, respond 0x00. App must retry from BEGIN.
ABORT command:
Discards any in-progress transfer immediately.HTTP endpoint for anchors¶
The app pushes the schedule to each anchor at:
POST http://<anchor-ip>/schedule
Content-Type: application/octet-stream
Body: <full schedule blob (no framing, entire blob in body)>
The anchor responds:
- 200 OK with body 0x01 on success.
- 400 Bad Request with body 0x00 on CRC or parse failure.
The CRC is appended to the HTTP body as the final 4 bytes (little-endian uint32) after all event data.
7. Constants & Tunables¶
All of the following must be defined as named constants in the firmware. Values marked "(tunable)" are expected to require adjustment during hardware testing.
// Timing
ENFORCEMENT_POLL_INTERVAL_S = 30 // (tunable) seconds between condition checks
BLE_SCAN_INTERVAL_S = 30 // seconds between BLE anchor scans in DORMANT state only
ENFORCEMENT_BLE_FALLBACK_SCAN_INTERVAL_S = 300 // (tunable) fallback BLE scan interval during ENFORCEMENT
// for anchor-based events when no motion is detected
ENFORCEMENT_SCAN_DURATION_MS = 300 // (tunable) BLE scan duration per enforcement scan;
// 300 ms covers 3 anchor advertisement windows (100 ms each)
ENFORCEMENT_IDLE_BEFORE_SLEEP_MS = 3000 // (tunable) ms of no motion before entering enforcement sleep
WIFI_SCAN_INTERVAL_S = 60 // seconds between WiFi scans in dormant
DORMANT_TO_SLEEP_IDLE_MS = 5000 // ms of inactivity before entering dormant-sleep
ANCHOR_WIFI_RETRY_INTERVAL_S = 30 // anchor WiFi reconnect attempt interval
ANCHOR_SEEN_TIMEOUT_S = 10 // seconds after which anchor is considered "away" if not seen
// UDP
ANCHOR_UDP_PORT = 5555 // (tunable) UDP listen port on anchors
UDP_RETRY_COUNT = 3 // attempts before marking anchor unreachable
UDP_RETRY_TIMEOUT_MS = 500 // ms timeout per UDP attempt
MDNS_RESOLVE_TIMEOUT_MS = 1000 // ms timeout for mDNS resolution
// Anchor settings
ANCHOR_MAX_BEEP_MINUTES_DEFAULT = 30 // default max continuous beeping per event
IDENTIFY_BEEP_DURATION_MS = 800 // duration of identify beep
// Anchor servo
SERVO_CLOSED_DEGREES = 180 // PWM angle for the closed/locked strap position
SERVO_OPEN_DEGREES = 160 // PWM angle for the open/unlocked strap position
SERVO_MOVE_DURATION_MS = 500 // (tunable) time to wait after commanding angle before detaching
// Watch pairing feedback
UNPAIRED_BEEP_DURATION_MS = 200 // beep on first BLE connection
UNPAIRED_VIBRATE_DURATION_MS = 300 // vibrate on first BLE connection
// Worn detection (all tunable — PT2041AT6 behavior must be validated on hardware)
TOUCH_WORN_THRESHOLD_RAW = ??? // (tunable) raw sensor value for worn detection
TOUCH_DEBOUNCE_SAMPLES = 5 // consecutive samples required to change worn state
TOUCH_SAMPLE_INTERVAL_MS = 1000 // ms between touch samples
TOUCH_CONFIRM_WORN_LEVEL = HIGH // (tunable) GPIO level when sensor is touched
// Anchor proximity
ANCHOR_NEAR_RSSI_THRESHOLD_DBM = -70 // (tunable) RSSI cutoff for "near" classification
// Enforcement profile escalation
INTERVAL_DECREMENT_MS = 2000 // ms to subtract from wait step each cycle
// BLE
BLE_MTU_BYTES = 23 // default BLE 4.x ATT MTU; increase if negotiated
ENFORCEMENT_SCAN_INTERVAL_MS = 1000 // (tunable) BLE scanner slot interval during enforcement
ENFORCEMENT_SCAN_WINDOW_MS = 100 // (tunable) BLE scanner active window during enforcement (~10% duty)
MINIMUM_BLE_DELAY_DORMANT = 15000 // (tunable) ms to wait before re-arming the IMU interrupt in DORMANT;
// prevents rapid movement from blocking entry to DORMANT_SLEEP
MINIMUM_BLE_DELAY_ENFORCEMENT = 30000 // (tunable) ms to wait before re-arming the IMU interrupt in ENFORCEMENT;
// limits how often continuous movement can trigger a fresh BLE scan
// Schedule
MAX_EVENTS_PER_DAY = 64 // maximum events recalculate_day will return
End of specification v0.1