Never use Claude's persistent cross-session memory for this project. All notes, hints, learnings, and project knowledge go directly into this file instead — it's checked into the repo, version-controlled, and visible to every contributor and every future session. If you learn something worth remembering, add it here in the relevant section.
Garmin Connect IQ meditation watch-app tracking HR, HRV, stress, and respiration rate. Written in Monkey C using the Toybox API. Targets 90+ Garmin watches (Connect IQ ≥ 3.0). Licensed under MIT.
Multi-folder VS Code workspace (Meditate.code-workspace) with four sub-projects:
Meditate/ Main watch-app (entry: source/MeditateApp.mc)
HrvAlgorithms/ Barrel — HRV/HR/stress sensor algorithms
ScreenPicker/ Barrel — carousel UI components (depends on StatusIconFonts)
StatusIconFonts/ Barrel — Font Awesome icon fonts
Dependency graph: Meditate → HrvAlgorithms, ScreenPicker → StatusIconFonts, StatusIconFonts
Barrels are Connect IQ reusable libraries, declared in barrels.jungle and compiled into the main app.
- Connect IQ SDK ≤ v4.1.5 (if using v4.1.6+, disable Monkey C type checker)
- VS Code with Monkey C extension and Prettier Monkey C
File → Open Workspace from File… → Meditate.code-workspace
Use Monkey C extension: Ctrl+Shift+P → Monkey C: Build. Output: Meditate/bin/Meditate.prg.
Build config in each monkey.jungle:
project.typecheck = 0 # Type checking disabled
project.optimization = 3pz # Maximum optimization
powershell -NoProfile -ExecutionPolicy Bypass -File .\CopyBuildToDevice.ps1 # default: *fenix*
powershell -NoProfile -ExecutionPolicy Bypass -File .\CopyBuildToDevice.ps1 "fenix 8" # specific deviceCopies bin/Meditate.prg to <Device>\GARMIN\Apps\ via MTP.
Always release via makeRelease.sh — never hand-roll the version bump / commit / tag steps.
The script (run from repo root) does the full flow in order:
- Prints the current
manifest.xmlversion, then promptsMake release for version:(read from stdin). - Seds the entered version into every
about_AppVersion">vX.X.X<string (all localestrings.xml) and intoMeditate/manifest.xml's<iq:application version="...">. git add .→git commit -am "bump version to vX.X.X"→git tag vX.X.X→git push origin tag vX.X.X→git push.
It is interactive, so feed the version on stdin via the Bash tool:
echo "10.7.7" | ./makeRelease.sh # non-interactive: pipes the version into the promptNotes:
- The version sed is idempotent — safe to re-run.
- Before every release, check whether any of the changes since the last release require an update to
UserGuide.md(e.g. renamed/added/removed settings, menus, or features the guide documents). Update it and stage the edit before runningmakeRelease.shso it lands in the bump commit. - Stage any other intended changes (e.g. doc edits) before running; step 3's
git add .sweeps the whole tree into the bump commit. - The final
git pushuses SSH (git@github.com:...). If the agent has no loaded key / a passphrase-protected key, push fails after the local commit+tag succeed — finish with a manualgit push origin dev && git push origin tag vX.X.X.
Each of the 4 manifest.xml files lists supported watches as <iq:product id="<deviceId>"/> entries inside <iq:products>. The <deviceId> matches the folder name under the SDK's device-definition directory.
- SDK device definitions (source of truth for available watches):
- Windows:
%APPDATA%\Garmin\ConnectIQ\Devices\(=C:\Users\<user>\AppData\Roaming\Garmin\ConnectIQ\Devices\) - macOS/Linux:
~/.Garmin/ConnectIQ/Devices/ - One folder per device (
fenix8,vivoactive6, …), each with<deviceId>.bin,simulator.json,compiler.json. The folder name is the manifest product id.
- Windows:
- The 4 manifests are not identical:
Meditate/manifest.xmlis the actual app device list; the barrels (HrvAlgorithms,ScreenPicker,StatusIconFonts) typically list a superset. The app-facing list isMeditate/manifest.xml.
This app needs at least 512 KB of watchApp memory to fit. Every currently-supported device meets this; nothing below it is supported (smallest supported = fr255s at 512 KB). Devices below the floor are excluded, e.g. instinct3solar45mm (128 KB) and the whole Instinct 2 family (96 KB).
The budget is in each device's compiler.json under appTypes → watchApp → memoryLimit (bytes). Check any device:
$id = 'instinct3solar45mm' # device id to check
$j = Get-Content "$env:APPDATA\Garmin\ConnectIQ\Devices\$id\compiler.json" -Raw | ConvertFrom-Json
$wa = ($j.appTypes | Where-Object { $_.type -eq 'watchApp' }).memoryLimit
if ($null -eq $wa) { "$id: no watchApp app type — cannot run this app" }
elseif ($wa -lt 524288) { "$id: $([int]($wa/1024)) KB — BELOW 512 KB floor, do NOT add" }
else { "$id: $([int]($wa/1024)) KB — meets 512 KB minimum" }Memory is necessary but not sufficient. A device can have ≥512 KB and still be excluded. Known reasons (from git history), so they aren't re-evaluated blindly:
- CIQ API below 3.0 —
epixgen 1 is CIQ 1.2.1, below the app's floor. Gate new devices onconnectIQVersiontoo (incompiler.json), not just memory. - App broke on the device —
vivoactive4/vivoactive4s(1024 KB) were removed in v8.5: commit30af8d0"Removed support for Vivoactive 4(s) - app no longer working in these devices". Needs a code fix, not just a manifest line. - Parked, revisit later —
vivoactive3m/vivoactive3mlte,marqexpeditionwere temporarily removed (commit46c1938"tmp rm devices again; try to support later"); still in the barrel manifests.
So a new device passing the memory check still warrants a judgment call (CIQ version, form factor, and a sim build) before adding.
vívoactive4/4s report Connect IQ API >= 3.3.6 (the Utils.MonkeyVersionAtLeast([3,3,6]) gate in MeditateActivity.mc that's meant to guard Meditation/Yoga/Breathing FIT sport support), but ActivityRecording.createSession still throws "Invalid Value" for SPORT_MEDITATION (67) on this hardware — first seen as a production crash (backtrace HrActivity.initialize ← HrvActivity.initialize ← MeditateActivity.initialize, vívoactive4S firmware 8.30, app v10.7.8, 2026-07-03). Garmin's manuals confirm this device ships native Yoga and Breathwork activities but never got a native Meditation profile, so the API-level heuristic is a false positive specifically for this device family.
Fixed via Utils.activityTypeOverridesByPartNumber (keyed by System.getDeviceSettings().partNumber — 006-B3225-00/006-B3388-00 = vivoactive4, 006-B3224-00/006-B3387-00 = vivoactive4s) and Utils.getEffectiveActivityType(), applied once where MeditateActivity.mc resolves selectedActivityType — remaps ActivityType.Meditating to ActivityType.Breathing on these devices. That single remap point also fixes wakeup-resume, since mEffectiveWakeupSessionType → WakeupSessionStorage → HeartbeatIntervalsSensor branches on the same enum. Add new devices/overrides to that table rather than writing new one-off boolean checks.
Note: ActivityRecording.createSession does not throw a catchable exception for this failure — a try/catch-and-retry safety net was considered and rejected because it wouldn't actually intercept it. Don't propose try/catch around a Toybox call without first confirming (via the API docs or existing repo precedent) that it's documented to throw.
Goal: surface only genuinely new watch releases. A raw SDK-vs-manifest diff returns ~76 entries that are not new — they're non-wrist hardware and old watches already intentionally dropped. Folder timestamps can't distinguish new from old (they all reset to the SDK install date). So the diff is filtered against a curated baseline of known exclusions; anything left over is a genuinely new device to evaluate.
Trigger whenever Garmin releases new watches (or after an SDK update):
- List device ids in the SDK
Devices/folder (folder names). - Diff against
<iq:product id=...>ids inMeditate/manifest.xml. - Filter out non-wrist hardware, the dropped-watch baseline, and anything below the 512 KB floor (see above). Whatever remains is new and viable — report it with its memory.
- For each candidate, propose adding
<iq:product id="<deviceId>"/>to all 4 manifests (keep barrels a superset ofMeditate). If a candidate is non-wrist or otherwise unwanted, add its id to$excludeExact. - Rebuild in the simulator against one new device to confirm it compiles.
The memory gate auto-drops sub-512 KB devices (e.g. instinct3solar45mm), so the name baseline only needs non-wrist prefixes plus capable-but-unwanted old watches.
# Non-wrist hardware — never a target for a meditation app (note: d2* are wrist aviation watches, NOT excluded)
$excludePrefixes = 'approach','edge','gpsmap','oregon','montana','rino','etrex','descent'
# Capable (>=512 KB, CIQ >= 3.0) but intentionally excluded — see "Minimum memory requirement" above
$excludeExact = @(
'vivoactive3m','vivoactive3mlte', # array-out-of-bounds crash on session finish (240x240 layout); kept in barrels only
'system8preview' # SDK System-8 preview pseudo-device, not a real watch
)
# NOTE: vivoactive4/4s and marqexpedition were sim-verified OK and re-added to all 4 manifests after the
# Apr-2025 bulk purge (46c1938 "tmp rm devices again"). vivoactive3m/3mlte still crash on finish.
# NOTE: fr70 / fr170 / fr170m were added to all 4 manifests (CIQ 6.0, 768 KB); flow now skips them via $man.
$man = Select-String -Path .\Meditate\manifest.xml -Pattern 'iq:product id="([^"]+)"' -AllMatches |
ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[1].Value }
Get-ChildItem "$env:APPDATA\Garmin\ConnectIQ\Devices" -Directory | ForEach-Object {
$id = $_.Name
if ($id -in $man -or $id -in $excludeExact -or ($excludePrefixes | Where-Object { $id -like "$_*" })) { return }
$j = Get-Content "$($_.FullName)\compiler.json" -Raw | ConvertFrom-Json
$wa = ($j.appTypes | Where-Object { $_.type -eq 'watchApp' }).memoryLimit
$ciq = ($j.partNumbers.connectIQVersion | ForEach-Object { [version]$_ } | Sort-Object | Select-Object -First 1) # min across SKUs
if ($wa -ge 524288 -and $ciq -ge [version]'3.0.0') { # 512 KB floor AND CIQ >= 3.0
[pscustomobject]@{ id=$id; KB=[int]($wa/1024); CIQ=$ciq }
}
} | Sort-Object id | Format-Table -AutoSize # empty = nothing new; drops epix (CIQ 1.2.1) by ruleUnit tests exist in HrvAlgorithms/sources/activity/hrv/tests/ but are commented out to reduce PRG binary size. They use Connect IQ's (:test) annotation framework and return true/false. To run: uncomment test files, then use the Monkey C extension test runner or Connect IQ simulator.
No CI pipeline builds or tests Monkey C code. GitHub Actions handle only image compression, content translation, and user guide publishing.
| Element | Convention | Example |
|---|---|---|
| Classes / Modules | PascalCase | MeditateActivity, VibePattern |
| Methods | camelCase | loadHrvTracking() |
| Private fields | m prefix |
mSessionStorage, mHrvTracking |
| Public fields | camelCase (no prefix) | elapsedTime, currentHr |
| Enum values | PascalCase | NoNotification = 0 |
| Storage keys | snake_case strings | "globalSettings_hrvTracking" |
- MVC-like: Model (data) → View (render) → Delegate (input). Example:
MeditateModel/MeditateView/MeditateDelegate. me.prefix used consistently for instance member access.- Inheritance chain:
MeditateActivity → HrvActivity → HrActivity → SensorActivity. - Dictionary serialization: Models use
fromDictionary()/toDictionary()forApp.Storagepersistence. - Static load/save:
GlobalSettingsuses static methods per setting key. - Barrel modules: Each barrel wraps code in a module (e.g.,
module HrvAlgorithms { ... }).
- Tab indentation, LF line endings
- Format-on-save enabled via Prettier Monkey C
- Braces on same line:
function initialize() { - Comments: concise, lowercase, no full sentences — just the point (e.g.
// clear stale paused; else multi-session drops HRV after session 1). One line, not a paragraph — this applies to agent-written comments too, don't explain background/history/rationale inline, that belongs in the commit message
- Always super concise. Lead with the behavior change (what the user notices); add technical detail only if it's needed to understand the change.
- Start with user-facing release notes (what changed for the user)
- Follow with technical details below (implementation specifics, files changed, reasoning) — only when they add necessary context
- No special characters that shells may misinterpret: avoid parentheses, colons, slashes, quotes, brackets, backticks, and dollar signs in the message text
- When providing via terminal, use the temp-file approach: write to a file with
Set-Content, thengit commit -a -F <file>, then delete the file - After creating a commit, read the commit message back verbatim in the reply — do not just assert that it follows the convention
- Always customer-facing, value-focused, super concise. Describe what the user gets, not how it was built.
- No internal/technical detail (file names, refactors, SDK plumbing) — that belongs in commit messages only.
MeditateApp.getInitialView()
→ HeartbeatIntervalsSensor.startup()
→ SessionStorage (load sessions / presets)
→ SessionPickerDelegate (carousel)
→ [Start] → Preparation → MeditateActivity → Finalization → Save/Discard → Summary screens
→ [Multi-session: intermediate menu → next session or rollup exit]
Meditate/source/activity/— Core meditation activity, views, vibration alertsMeditate/source/sessionSettings/— Session config, color/custom pickers, interval alertsMeditate/source/summaryScreen/— Post-session summary with HR/HRV/stress/respiration graphsMeditate/source/globalSettings/— App-wide settings (static load/save)Meditate/source/storage/— Session CRUD, presetsMeditate/source/com/— GA4 analytics, donation promptsHrvAlgorithms/sources/activity/hrv/— HRV algorithm implementations (RMSSD, SDRR, pNNx)
App.Storage— Key-value persistence for sessions, settings, analytics queue.- Session keys:
"sesssion_<key>"(historical triple-s typo — do not fix) - Settings keys:
"globalSettings_<name>"
- Session keys:
App.Properties— Device-configurable properties (activity name, GA4 credentials)
Connect IQ caps the number of concurrently active Timer.Timer objects per app. The limit is device-dependent with a default of 3 (and a default minimum interval of 50 ms); both "depend on the host system" per the API docs. Starting one more than the device allows throws the runtime "Too Many Timers Error". A Timer.Timer is a native resource — its slot is held until you call .stop() (or, unreliably, until the object is garbage-collected). Always .stop() a timer before dropping its reference; do not rely on GC, especially on slower watches. Sensor.registerSensorDataListener (used by HeartbeatIntervalsSensor) is not a Timer and does not count toward this limit.
Timers in this app (keep this list current when adding/removing timers):
| Timer | Where | Repeating | Released by |
|---|---|---|---|
mRefreshActivityTimer |
HrActivity (1 s session refresh) |
yes | stop() / pauseResume() |
mviewDrawnTimer |
MeditatePrepareView (prepare/finalize countdown) |
yes | onHide() → stop() |
viewDrawnTimer |
DelayedFinishingView (1 s finish delay) |
no (one-shot) | onHide() → stop() |
mTimer |
IdleReminderTimer (10 min idle vibe) |
yes | stop() |
notifyChangeTimer |
AddEditIntervalAlertMenuDelegate (500 ms debounce, settings only) |
no (one-shot) | fires then nulls |
Steady state holds ≤2 of these at once (session refresh + an idle-reminder while a menu is up), well under the 3-timer floor. The finish flow is the tight spot: it chains two DelayedFinishingView instances and then starts the IdleReminderTimer, so any leaked finishing-view timer slot can tip a 3-timer device over. This is exactly the historical "Too Many Timers Error" (backtrace IdleReminderTimer.start ← showSummaryView ← DelayedFinishingView.onViewDrawn): fixed by having DelayedFinishingView.onHide() call .stop() instead of only nulling the reference, matching MeditatePrepareView.
Meditate/resources/secrets.xml is gitignored. Copy secrets_template.xml → secrets.xml and fill in GA4 credentials to enable usage analytics.
bin
.metadata
export
Meditate/resources/secrets.xml
Meditate/backup
Meditate/debug-pulls
Two PowerShell 5.1 scripts for deploying and debugging on a physical Garmin watch via MTP (USB). Both scripts accept an optional device name parameter (default: fenix).
Deploys bin/Meditate.prg to the watch:
- Shows device and source path, asks for confirmation before deploying
- Copies
Meditate.prgtoGARMIN/Apps/and verifies it arrived - Creates an empty
MEDITATE.TXTinGARMIN/Apps/LOGS/to enableSystem.println()logging
Pulls all debug-relevant files from the watch into a timestamped debug-pulls/ subfolder:
GARMIN/Apps/LOGS/-- println output (MEDITATE.TXT) and crash logs (CIQ_LOG.YAML)GARMIN/CIQLOG/-- Connect IQ system logsGARMIN/ERR_LOG.txt-- device/firmware crash logs
- Connect IQ on-device logging:
System.println()writes toGARMIN/Apps/LOGS/<APPNAME>.TXT, but the file must already exist (empty) on the device. The filename matches the PRG name in uppercase (e.g.,Meditate.prg->MEDITATE.TXT). CIQ_LOG.YAMLis auto-created by the runtime on app crashes only -- not a trigger file for logging.- Garmin app storage locations:
GARMIN/Apps/DATA/for Object Store data,GARMIN/Apps/SETTINGS/for phone-configured settings. Older devices use UUID-named subfolders (e.g.,DATA/3A747E00-.../). Newer devices (fenix 8+) use short encoded filenames (e.g.,G1HF1837.DAT,G1HF1837.SET) with no UUID in the name. - CIQ data files are encrypted per-build:
.DAT(Application.Storage) and.IDX(index) files are encrypted with a build-specific key. Same-build backups produce byte-identical DATs, but cross-build DATs are entirely different. Restoring a DAT from a different build causes the app to crash on first launch; the CIQ runtime then resets the corrupt store..SET(Application.Properties) files contain plaintext key-value pairs but property ordering and offset tables change between builds -- they can usually be restored across builds..IMT(install metadata) contains build-specific hashes and varies in size per build. Only same-build restores of DAT/IDX are reliable. Cross-build restores should only include SET files. GarminDevice.xmlin theGARMIN/root contains an<IQAppExt>section that maps each installed CIQ app to its short filename. Each<App>entry has<AppName>,<StoreId>,<AppId>(= manifest UUID), and<FileName>(e.g.,G1HF1837.PRG). The base name (without extension) is the short ID used across DATA, SETTINGS, and LOGS folders. Scripts parse this file to back up only the target app's files.- MTP access in PowerShell: Use
Shell.ApplicationCOM object. MTP paths (e.g.,Dieser PC\fenix\Internal Storage) are not regular filesystem paths -- you must navigate via Shell folder objects.CopyHerealways preserves the original filename -- to copy to a predictable path, use a unique temp subdirectory rather than renaming the destination. - PowerShell 5.1 encoding: Files without a UTF-8 BOM are read as ANSI (Windows-1252). Non-ASCII characters (em dashes, box-drawing chars) in strings will cause parse errors. Always use ASCII-only content or save with UTF-8 BOM.
In-app developer tool accessible via long-press on the About screen → "Dev Tools" menu. Backed by Firebase Realtime Database (meditate-garmin project).
Whenever an Application.Storage key is added, renamed, or removed, update both:
Meditate/source/devTools/CloudBackup.mc—GLOBAL_SETTINGS_KEYSconstant array (for serialization)Meditate/source/devTools/CloudRestore.mc—onRestoreResponse()method (for deserialization)
globalSettings_*(12 keys) — app-wide settingssessionsKeys— list of session IDsselectedSessionIndex— active session indexsesssion_<key>(per entry insessionsKeys) — individual session data (note: triple-s typo is intentional)wakeupSession_activityTypeusageStats_monthly— current month meditation time (viamonthlyStatssection)usageStats_tipPending— pending tip flag (viamonthlyStatssection)
Not backed up: usageStats_queue_v2 (too large, auto-rebuilds).
- Firebase auth via legacy database secret appended as
?auth=<SECRET>query param - Firebase credentials are in
secrets.xml(gitignored) viaApp.Properties— they do NOT appear in Garmin Connect Mobile because they are not listed insettings.xml restoreDeviceIdproperty IS listed insettings.xml→ configurable in GCM to restore another device's backups- All HTTP callbacks use an
mActiveboolean guard to prevent zombie callbacks from touching the view stack after navigation - Use
Ui.switchToView(notpushView+popView) from HTTP callbacks —popViewfrom a callback corrupts the view stack - Backup list is trimmed to the 10 most recent entries in code (after sort); old entries remain in Firebase but are never shown
properties.xmlis only needed for properties referenced bysettings.xml(via@Properties.<id>). Secrets used only in code (e.g. Firebase URL/secret, GA4 credentials) need only asecrets.xmlentry —properties.xmlis not required for them and should be omitted to avoid redundancy.- Firebase RTDB has no native TTL — that feature exists only in Firestore (via Cloud Functions). For a dev tool, trimming the displayed list to the N most recent entries after sorting is sufficient; no Firebase config or cleanup code needed.
settings.xmlstring IDs must be defined in ALL locale resource folders. Any string referenced via@Strings.<id>insettings.xml(e.g. as atitle=) must exist in everyresources-<lang>/strings/strings.xml, not just the baseresources/folder. A missing locale string produces aWARNING: String id '...' undefined for language '...'and triggers the generic "A critical error has occurred" compiler crash.- Static methods cannot access
privateinstance members or callprivateinstance methods, even on a freshly created instance of their own class. Doing so causes the assembler errorTrying to add undefined symbol: <memberName>during release builds. The fix is to move all initialization that touches private members intoinitialize(), so thestatic run()factory simply callsnew MyClass(). - "A critical error has occurred" is a compiler crash masking real errors. Re-run with
--debug-log-level 2 --debug-log-output <file>.zipto geterror.txtinside the zip, which lists the actualCompilerExceptionmessages (e.g., assembler symbol errors, missing strings). - Runtime device identification: Monkey C does not expose the SDK's device-id string (e.g.
"vivoactive4") at runtime. The closest proxy isSystem.getDeviceSettings().partNumber, a hardware SKU string (e.g."006-B3225-00") matching thepartNumbers[].numberentries in that device'scompiler.json. Each device model can have multiple part numbers (one per regional/firmware SKU), so device-specific checks need the full list, not a single value — see the vívoactive4/4s quirk above for a working example. - Verify a Toybox API throws before wrapping it in try/catch.
ActivityRecording.createSessiondoes not throw a catchable exception for an unsupported sport/subSport combo — confirmed via the vívoactive4/4s "Invalid Value" crash above. Check the API docs or existing repo precedent (e.g.Sensor.TooManySensorDataListenersException,Attention.BacklightOnTooLongException) before assuming a call is catchable. - CLI builds outside the VS Code extension need a private key (
-y) even for unsigned debug builds — generate a throwaway one withopenssl genrsa+openssl pkcs8if just verifying compilation. Jungle file paths in-fare resolved relative to the jungle file's own directory, not the invocation cwd — passMeditate/monkey.jungle;Meditate/barrels.jungletogether (the auto-generatedbin/combined.jungleuses paths meant for the extension's own resolution and won't work standalone).