A simple Web Application that uses Web BLE to connect, read and write settings of a OpenCollar Edge device.
This app is a static web app. There is no build step and no package.json.
Serve the repository root over a local HTTP server instead of opening index.html directly from disk:
cd /home/tim/apps/ble-settings-app
python3 -m http.server 8000Then open:
http://localhost:8000/for the main BLE settings apphttp://localhost:8000/composer.htmlfor the HEX composer
Notes:
- Use a Chromium-based browser such as Chrome or Edge, because the app uses Web Bluetooth / Web BLE.
localhostis required here because the app registers a service worker and fetches local JSON assets. Opening the files with afile://URL will not work correctly.
Adding a new settings.json version:
- upload settings.json file to settings folder
- add settings.json version to functions.js (
SETTINGS_FILES, newest first) - add settings.json version to service-worker.js and bump
CACHE_NAME - update the expected file in
tests/settings-protocol.test.cjs("schema selection") - add firmware release notes to
device-version-notes.jsonif you want notes shown in the UI
Safari, Chrome and every other browser on iOS cannot talk to Bluetooth devices, so the app does not work in them. Use the free Bluefy app instead; it is a web browser with Bluetooth support.
- Install Bluefy – Web BLE Browser from the App Store: https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055
- Open Bluefy and enter the app address in its address bar: https://smartparksorg.github.io/ble-settings-app/ (opening the address in Safari shows a notice with a link to Bluefy instead of a Scan that works).
- Turn on Bluetooth on the phone and allow Bluefy to use it when iOS asks.
- Tap Scan. The list shows every Bluetooth device nearby, not only collars. To shorten it,
open Scan settings first and type the start of the collar name, for example
SP05. - Pick the collar. From here the app works as on Android: settings, panels, logs and DFU.
Things that work differently on an iPhone:
- Export settings and Save partial log open the iOS share sheet instead of downloading a file. Choose Save to Files (or Notes, Mail, AirDrop). If the share sheet does not appear, a dialog shows the file contents with a Copy button; paste them into Notes.
- A finished Download all logs shows the same dialog, and the result box keeps a Save file button to reopen it.
- Import settings uses the iOS file picker; pick the JSON you saved to Files earlier.
- DFU is slower than on Android (each packet waits for a reply). Keep Bluefy in the foreground until the collar has rebooted and reconnected.
- Bluefy does not install the app on the home screen or keep an offline copy; it needs an internet connection to load the page.
If something does not work, open the app menu, turn on Debug and send the log along with the iPhone model, iOS version and Bluefy version (Bluefy settings show it).
The app needs Web Bluetooth: Chrome or Edge on Windows, Android, macOS and Linux, and
Bluefy on iOS, a WebKit
browser with its own Web Bluetooth stack. The app detects iOS WebKit (and a missing
navigator.bluetooth) and adapts; nothing changes for Chrome and Edge:
- The Scan card shows a notice: a link to Bluefy when Web Bluetooth is missing on iOS, a hint to use Chrome or Edge elsewhere, and on Bluefy an explanation that the picker lists every nearby device. Scan without Web Bluetooth shows a toast instead of throwing.
- Bluefy ignores
manufacturerDatascan filters (a filter carrying one matches nothing), so on iOS the picker is opened with the typed name prefixes only, or withacceptAllDevices. Other browsers keep the manufacturer filter; aTypeErrorfrom an unsupported filter shape retries the same relaxed way, and a cancelled picker is never reopened (requestUartDevice). - Bluefy has no
writeValueWithoutResponse. The main page already preferred with-response writes;dfu/mcumgr.jsnow falls back towriteValueWithResponseand thenwriteValue, so DFU works there too (slower, one round trip per SMP packet; the usual MTU step-down applies). - iOS cannot download a Blob through an anchor.
saveTextFileCompatinfunctions.jskeeps the download on other platforms; on iOS it opens the share sheet when called from a tap (export, save partial log) and otherwise a dialog with the file contents, a Share button (when the browser can share files) and a Copy button. A finished log download shows a "Save file" button in the result overlay so the share sheet can be opened from a tap. - The DFU wake lock falls back to Bluefy's
bluetooth.setScreenDimEnabled(false)where the Wake Lock API is missing. viewport-fit=coverplusenv(safe-area-inset-bottom)padding keep the pending-changes bar and the bottom bars above the iPhone home indicator.
tests/browser-input-check.mjs ... ios emulates an iPhone running Bluefy (user agent, touch,
stubbed device picker, stubbed downloads) and checks these paths; the desktop and mobile runs
check that exports still download through an anchor and that the manufacturer filter stays.
Verification on a real iPhone with Bluefy is still open (see to-do.md).
The app and HEX composer support both the legacy settings protocol and the family-based
protocol released in OpenCollar v8.0.0. The bundled settings/settings-v8.0.0.json is the
unmodified v8.0.0 release asset,
settings/settings-v8.0.1.json, settings/settings-v8.0.2.json and settings/settings-v8.0.3.json
are the unmodified
v8.0.1,
v8.0.2 and
v8.0.3 release assets.
The v8.0.1, v8.0.2 and v8.0.3 schemas are identical in content to v8.0.0; they are bundled so the app
selects the latest patch release and shows its firmware notes. Bundled DFU releases live in
assets/dfu/releases/open-collar-v8.0.<patch>/ and assets/dfu/releases/air-quality-v8.0.<patch>/.
The v8.0.2 DFU bundles were removed on 2026-10-07 so the built-in list offers v8.0.3, which
fixes the missing error_ublox status flag; the v8.0.2 settings schema stays so devices still
on 8.0.2 can be read and updated.
- Legacy settings use
id length data; v8 settings usefamily id length data. - Runtime value responses also include a family byte in v8 (currently
0xA0). - Commands retain
id length data. Single-setting (A8) and single-value (A3) requests includefamily idas their two-byte payload in v8. - Bluetooth and satellite payloads prepend the port; LoRaWAN uses the port separately. Values remain little-endian; the address is always sent as family first, then ID.
functions.js normalizes family-based settings and values to a unique 0xFFII address
in memory for DOM IDs and lookups, preserving the original byte ID as wireId. Use
getProtocolAddress, encodeProtocolRecord, and encodeReadRequest for wire data;
do not serialize the normalized ID as a single byte. JSON import/export remains keyed
by setting name, so existing profiles can be imported across the protocol change.
Automatic schema selection matches numeric major/minor versions. A manual selection with the wrong protocol is rejected. Future firmware schemas must still be bundled when settings change; the family-based format alone does not guarantee compatibility. The firmware documentation calls the development transition v7.4; v8.0.0 is the published release that contains it.
Protocol references:
The firmware migrates stored settings itself. Downgrading to older firmware uses the old storage addresses and can restore defaults; export a settings profile before a firmware change. Bundled DFU releases are managed separately from settings schemas.
Run the dependency-free regression tests with Node.js:
node --test tests/settings-protocol.test.cjs tests/mcumgr.test.cjs tests/panel-engine.test.cjstests/browser-input-check.mjs drives both pages in headless Chromium with real key, mouse and
touch events (desktop viewport, an emulated Android phone, and an emulated iPhone running Bluefy
with the third argument ios) and checks that every kind of panel input
stays editable while a value is retyped, that the settings-list interval never turns an empty field
into 0, and that the one-tap Apply flow writes, reads back and reports correctly against a fake
device. It needs a local server and a Chromium binary (CHROME=...), see the header of the file.
The settings list is being reworked into guided, task-oriented panels. Eleven panels sit above the settings list: Positioning (GPS), Data sending and storing (a message-type matrix over the LoRaWAN, satellite, LP0 and flash-store flags), Network (LoRaWAN), Device and security, Iridium satellite, VHF beacon, WiFi and BLE scanning, Tracker search (CMDQ), Fence monitor, External switch, and Sensors and diagnostics. The three schedules (GPS, satellite, VHF) share one component: a switch, a schedule type (fixed or day/night), intervals, and a 24-hour bar. A search box above the panels finds settings by label, help or key, and each panel can load the firmware defaults into the pending changes. The older feature cards (CMDQ, FenceEdge, WiFi scan, BLE scan) keep their actions and results; their settings blocks are hidden because the panels edit those settings now.
-
panels/panel-renderer.jsrenders panels from the definitions. A panel is a view over the same state as the settings list: it reads effective values (pending edit, else the device value, else the default) and writes through the list inputs, so validation, the pending draft and "Review and apply" are shared. Fields whose keys the loaded schema lacks are skipped; fields gated by another setting are greyed out with the reason. -
panels/panel-engine.jsholds the draft of pending edits, evaluates panel conditions, orders writes so dependents are written before a switch that enables them (and after one that disables them), and runs write-then-read-back verification. It has no DOM code and is tested intests/panel-engine.test.cjs. -
panels/panel-definitions.jsis the declarative panel format; the Positioning (GPS) panel is the reference definition. Keys absent from the loaded schema are skipped by renderers. -
settings-meta.jsoncarriescategorieswith a title, display order, and the order of settings inside each category; the settings list and the composer follow it instead of alphabetical order. -
Edits in the panels and the settings list collect in a draft. A bar offers "Apply", which writes in dependency order, reads each value back, shows progress in the bar and ends with a toast when the device confirmed everything; a mismatch, a failed write or an unconfirmed value opens a dialog with the outcome per setting. "Review" opens the before/after list first for people who want to check a larger edit. The per-setting Update buttons still work and clear their draft entry.
-
Import uses the same ordered, verified apply path, always with the preview first. Export and import refuse to run while edits are pending, so a profile always matches what the device reported.
-
Panel inputs never commit an empty or partial entry (a backspaced field, "5." on a number input) as 0: doing so flipped switches and gate conditions and disabled the field under the person's finger. The field is brought back in line with the draft when focus leaves it.
-
Once the header scrolls out of view, a slim bar pins to the top with the device name, a connection dot, firmware version, battery voltage, a pending-changes chip that jumps to the first edited field, and a small Disconnect button. Tapping the name scrolls back to the top. The bar hides on the DFU page and while disconnected; on narrow screens the firmware version and then the battery are dropped.
-
The Features card (the at-a-glance list above the panels) has a "Motion-triggered GPS" row of its own, since that switch is easy to leave on by accident and the GPS row only reflects the schedule. Its detail follows the Positioning panel's wording and says when the switch has no effect because scheduled fixes are off. Schedule starts in the card are shown in the browser's local time, not UTC, like the panels and the settings list.
-
The HEX composer mounts the same panels above its settings list. Editing a value in a panel ticks that setting for inclusion in the payload; unticking it in the list leaves it out again. Panel values that are not included show the firmware default.
-
Help text in
settings-meta.jsonand the panel definitions only states what the firmware READMEs document (VHF beeps per burst, S-Band send modes, port bitmasks, satellite retry timing, air quality duty cycle, report-empty options). Settings the firmware does not document keep a plain label without claims about behaviour.
Flash log downloads stream one packet per line (base64) into memory. If the connection drops,
the device stops answering, or the page is closed mid-download, the packets received so far are
not lost: the app saves them as raw_logs-<type>-<device>_<timestamp>_PARTIAL-<n>msgs.txt as
soon as the download is interrupted, and the overlay offers "Save partial log" and "Retry". While
a download runs, the packets are also mirrored to IndexedDB every 25 packets and on page unload;
on the next visit the app offers to save or discard an interrupted capture it finds there. The
device keeps all logs until "Erase all logs" is used, so Retry downloads the full set again from
the start. The last record in a partial file may be cut mid-message.
DFU runs over MCUmgr SMP on the same GATT connection as the settings UART. Selecting a
built-in version checks it against the device immediately and, when the check is clean,
"Start DFU upload" is the only further click. The upload keeps up to three SMP packets in
flight (the "Packets in flight" setting in the Advanced card); the device answers each
packet with the offset it expects next, so a lost packet shows up as a repeated offset and
is resent, and the client drops back to one packet in flight after any timeout, loss or
rejected write. After the image is uploaded and marked for test, the app resets the device
and reconnects to the retained BluetoothDevice object automatically; no browser chooser is needed because
gatt.connect() does not require a user gesture, only requestDevice() does. The app
keeps retrying for up to three minutes while MCUboot swaps the image, then reads the
image state over SMP, checks that slot 0 carries the uploaded hash, and returns to the
device screen. The firmware confirms its own image on boot, so the SMP confirm step is a
safety net rather than a requirement. A screen wake lock is held during upload and
reboot. If automatic reconnect fails, the overlay offers a retry and a manual scan.
Adding a new bundled DFU firmware release:
- upload
.binfiles toassets/dfu/releases/<release-id>/... - add release entries to
assets/dfu/manifest.json(release id, firmware version, and file paths) - bump
CACHE_NAMEinservice-worker.jsso clients fetch the new bundle - add firmware release notes to
device-version-notes.jsonif you want notes shown in the UI
App versioning shown in UI:
- the app reads
version.jsonand shows it in the header (bothindex.htmlandcomposer.html) - update
version.jsonon every deployment, preferably from GitHub Actions
Example GitHub Actions step to generate version.json on each deploy:
- name: Generate app version metadata
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
APP_VERSION="${GITHUB_REF_NAME}"
else
APP_VERSION="dev-${GITHUB_SHA::7}"
fi
printf '{\n "version": "%s",\n "commit": "%s",\n "built_at": "%s"\n}\n' \
"${APP_VERSION}" \
"${GITHUB_SHA}" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > version.json