Dog tracking collar: on a real device#
A GPS tracking collar with a HEART split in two: the noticing on the collar's own low-power chip, the HEART API and HEART Hub reporting on a mains-powered home base. The parts, the wiring, the code, how to build and install it, the factory steps, and what its owner sees.
For illustration only. The hardware below is an assumption, chosen so the steps can be concrete. Your modules, pins and parts will differ: check your own hardware. This product has not been built, and no dog wore one. The bare-metal C template is tested on Linux; its Cortex-M port compiles but has not run on a chip, and no port for the collar's chip exists yet. The bridge is tested with every device template, on Linux. The additions on this page (more fault codes in the C core, HEART Hub reporting in the bridge, the relay) are new code that has not been written or tested in the templates. Check every step on your own bench.
| Placement | A, split: noticing runs on the collar between fixes. The HEART API and HEART Hub reporting run on the home base, which has mains power. |
| Start from | On the collar: the bare-metal C template (heart/embeddedstandard/c-baremetal/v1.2611.3), "HEART Line". On the home base: the bridge (heart/embeddedstandard/bridge/v1.2611.3), plus the HEART Hub code of the Python template. See the templates. |
| Claim | HEART Basic, Autonomy Level A1. Changing the LED or the buzzer through HEART needs HEART Full (Section 9). |
| Try it first | The simulator and the design case study |
1. The device we assume#
The collar#
| Part | Assumed choice | Notes |
|---|---|---|
| Cellular and GNSS | A Nordic nRF9160 module (LTE-M and NB-IoT modem, GNSS receiver, an Arm Cortex-M33 application core) | The HEART core runs on the application core, in the collar's firmware. The nRF9160 has a separate GNSS antenna input. |
| Bluetooth | A Nordic nRF52832, beside the nRF9160, on a UART | The nRF9160 has no Bluetooth. This chip talks to the home base. |
| GNSS antenna | A ceramic patch on the top of the collar | It needs open sky |
| Accelerometer | A Bosch BMA400 on I²C, its interrupt line wired to wake the chip | Resting, walking, running; and whether the collar is on a dog |
| Temperature | A TI TMP102 on I²C, against the cell | A lithium cell must not get too hot |
| Fuel gauge | A Maxim MAX17048 on I²C | Reports the charge in % directly |
| Battery | A 3.7 V lithium-polymer cell, about 900 mAh, with a single-cell charger IC and charging contacts | As in the guide. Section 8 shows what it lasts. |
| LED and buzzer | A small LED and a piezo buzzer, each on a PWM pin through a transistor | Both act on the dog. Driven by the collar's firmware only, with its welfare guard |
| Button | A small recessed button | Wakes the collar from deep sleep. HEART does not use it. |
| Case | Sealed, with charging contacts | A dog swims and rolls in mud |
The home base#
| Part | Assumed choice | Notes |
|---|---|---|
| Board | A Raspberry Pi Zero 2 W (Wi-Fi and Bluetooth), on a 5 V mains adapter | Runs Linux, so it can run the bridge, which is Python |
| Status light | A green LED on GPIO17 (pin 11), through a 330 Ω resistor | Lights when the collar has a fault, with no internet |
| Network | The home Wi-Fi | Reports to HEART Hub, outbound only |
| Storage | Its microSD card | The device ID, claim code and key, in /var/lib/heart-collar |
Example wiring on the collar#
| Signal | nRF9160 pin (example) | Used by |
|---|---|---|
| I²C SDA, SCL (BMA400, TMP102, MAX17048) | P0.30, P0.31 | The collar's firmware, which fills the values HEART checks |
| BMA400 interrupt 1 | P0.29 | Wakes the chip on movement |
| UART to the nRF52832 | P0.00 to P0.03 | The firmware's Bluetooth link |
| LED (PWM) | P0.10 | The firmware only |
| Buzzer (PWM) | P0.11 | The firmware only, behind its welfare guard |
| Button | P0.12, pull-up | Wakes the collar |
The nRF9160 can route I²C, UART and PWM to most pins, so these numbers are only an example. Check your module's datasheet.
2. Where everything runs#
┌─────────── collar: nRF9160 + nRF52832, one firmware image each ───────────┐
│ Your collar firmware HEART Line (from c-baremetal) │
│ GNSS fixes, safe zone, position checks 7 rules at each wake │
│ reports, lost mode, LED, buzzer writes short lines to an outbox: │
│ and the welfare guard fault codes and status, no place │
│ the next uplink carries both: every 10 min, or 30 s in lost mode ───┼──┐
└─────────────────────────────────────────────────────────────────────────────┘ │
at home: Bluetooth ───────────────► home base │
away: LTE-M ──► maker's server ──► home base ◄───────────────────────────┘
┌──────────── home base: Raspberry Pi Zero 2 W, on mains ─────────────────────┐
│ collar-relay (yours): takes the HEART lines out of the collar's messages │
│ heart_bridge.py: HEART Basic API on 127.0.0.1:43278, alerts, Flatline │
│ + HEART Hub reporting (copied from the Python template) ───────────────┼──► heartcortex.ai
│ status LED │ (outbound only)
└──────────────────────────────────────────────────────────────────────────────┘
Positions, the track and safe-zone events go to the maker's app only. They never pass through HEART.
- The collar notices by itself. Each check runs on the collar, with no network. A fault is known on the collar even when nothing can be sent.
- The lines ride in messages the collar already sends. HEART adds about 45 bytes (one Pulse line) to each report, and an alert line when something changes. It never wakes the radio just to report health, except for a critical fault (Section 3.3).
- One place serves the HEART. At home the collar's messages reach the home base over Bluetooth. Away, they go over LTE-M to the maker's server, which already relays positions to the owner's app; it passes the HEART lines on to the home base too. This relay is a design assumption: a maker with no home base would run the bridge on its own server instead, one bridge per collar.
- The LED and buzzer are not HEART's. HEART never uses them, not even to escalate: they act on a living animal.
3. The code#
3.1 On the collar#
| File | What you do |
|---|---|
heart_line.h | Change the lines marked CHANGE (3.2), and add the rule type of 3.3 |
heart_line.c | Copy, and add the function of 3.3 |
heart_port_collar.c | Yours: put() into the outbox, the rule checks, and when to tick. Use port_cortex_m.c only as a model: it drives an STM32-style UART, which the nRF9160 does not have |
| Your collar firmware | Unchanged, except that it fills the col values and appends the outbox to each uplink |
The template's rule: call heart_line_start() once, then the tick on a timer, from the main loop or a low-priority task, never from a safety interrupt. On the nRF9160 (nRF Connect SDK, which is Zephyr), a low-priority delayable work item is a good place.
3.2 Identity and interval#
#define HEART_SYSTEM_ID "com.example.pets.tracking-collar" /* CHANGE: as in the guide's Card */
#define HEART_NAME "Example tracking collar" /* CHANGE */
#define HEART_VERSION "1.0.0" /* CHANGE */
#define HEART_INSTANCE "collar-1" /* CHANGE: must not identify a person or a dog */
#define HEART_PULSE_SEC 600 /* was 30: see below */
HEART_PULSE_SEC goes into the card line as interval. The bridge treats the collar as unknown after three intervals with no line (the Flatline rule). The collar normally reports every 10 minutes, so with the template's 30 the bridge would mark it unknown during every walk. With 600, a collar that has gone silent shows Unknown after 30 minutes. The checks still run more often (3.3).
3.3 More than one fault code#
Today the core raises only system.main-function-lost. The guide's table has seven collar faults, so add one function to heart_line.c, beside the template's own heart_line_tick(). It keeps the template's rules: no heap, no stdio, no floating point.
/* heart_line.h: add */
typedef struct { const char *code; const char *severity; int (*bad)(void); } heart_rule;
void heart_line_tick_rules(const heart_port *port, const heart_rule *rules, int n);
/* heart_line.c: add. Our change, not in the template. One alert per rule; a rule must hold for two checks
in a row, because one odd reading is not a fault. */
void heart_line_tick_rules(const heart_port *p, const heart_rule *rules, int n) {
static unsigned char held[16], open_[16];
int i, failing = 0, degraded = 0;
sequence++;
for (i = 0; i < n && i < 16; i++) {
int bad = rules[i].bad();
held[i] = bad ? (unsigned char)(held[i] < 2 ? held[i] + 1 : 2) : 0;
if (held[i] == 2 && !open_[i]) {
put_text(p, "{\"t\":\"alert\",\"code\":\""); put_text(p, rules[i].code);
put_text(p, "\",\"severity\":\""); put_text(p, rules[i].severity); put_text(p, "\"}\n");
open_[i] = 1;
} else if (!bad && open_[i]) {
put_text(p, "{\"t\":\"resolved\",\"code\":\""); put_text(p, rules[i].code); put_text(p, "\"}\n");
open_[i] = 0;
}
if (open_[i]) { if (rules[i].severity[0] == 'c') failing = 1; else degraded = 1; }
}
put_text(p, "{\"t\":\"pulse\",\"seq\":"); put_number(p, sequence);
put_text(p, failing ? ",\"status\":\"failing\"}\n" : degraded ? ",\"status\":\"degraded\"}\n" : ",\"status\":\"healthy\"}\n");
}
The bridge already takes any fault code and severity in an alert line, and degraded in a pulse line.
3.4 The rules (heart_port_collar.c)#
/* CHANGE: filled by your firmware after each fix, sensor read and uplink attempt. Whole numbers only.
There is no position here: HEART needs to know WHETHER there is a fix, never WHERE. */
extern struct { int battery_pct, temp_c, at_home, fix_fails, no_pos_s, link_down,
still_s, accel_same_s, moved_m; } col;
/* CHANGE: your own thresholds. These are the guide's (Step 5); a real collar waits about 30 min for "slipped off". */
static int main_lost(void) { return col.battery_pct <= 3 || (!col.at_home && col.fix_fails >= 3 && col.link_down); }
static int slipped(void) { return col.still_s >= 1800 && col.temp_c < 26; }
static int low(void) { return col.battery_pct < 15; }
static int hot(void) { return col.temp_c > 45; }
static int stuck(void) { return col.accel_same_s >= 60 && col.moved_m >= 40; }
static int nofix(void) { return !col.at_home && col.fix_fails >= 3 && col.no_pos_s >= 150; }
static int nolink(void) { return col.link_down; }
static const heart_rule RULES[] = {
{"system.main-function-lost", "critical", main_lost},
{"system.main-function-degraded", "warning", slipped},
{"power.battery-low", "warning", low},
{"performance.too-hot", "error", hot},
{"sensors.stuck", "warning", stuck},
{"sensors.no-reading", "warning", nofix},
{"connectivity.link-lost", "warning", nolink},
};
What is not a rule: the dog leaving the safe zone, the dog lying still, the dog being hot. Those are facts about the dog. The firmware sends them to the owner's app as private events (zone.left, zone.returned, heat.risk), as it does today, and never as HEART lines.
3.5 The outbox and the tick#
static char outbox[1024]; /* HEART Line: at most 1 KB per line */
static unsigned out_len;
static void put(char c) { if (out_len < sizeof outbox) outbox[out_len++] = c; }
static int check(void) { return !main_lost(); } /* for heart_line_start's port; unused by the rules tick */
void heart_collar_wake(void) { /* CHANGE: call at every wake, at least every 60 s, from a low-priority task */
static const heart_port port = {put, check};
static int started;
if (!started) { heart_line_start(&port); started = 1; }
heart_line_tick_rules(&port, RULES, (int)(sizeof RULES / sizeof RULES[0]));
}
Your uplink code appends the outbox to the next report and empties it once the report is acknowledged. Two rules for it:
- Keep only the newest Pulse line. Drop older
pulselines first if the outbox fills. Never drop analertorresolvedline. - A critical alert goes at once. If a
system.main-function-lostline is waiting, send a report now, if any link works. Warnings wait for the next scheduled report.
3.6 On the home base#
| File | What you do |
|---|---|
heart_bridge.py | Copy. Add HEART Hub reporting (below), and change its escalation line |
collar-relay | Yours: receives the collar's messages, over Bluetooth from the collar and over the internet from the maker's server, and writes each HEART line to standard output |
The bridge reads lines from --stdin or --serial, serves HEART Basic on 127.0.0.1:43278 (or 7685), and answers 501 not-implemented to every change. It does not report to HEART Hub. To add that, copy these from the Python template's heart.py: CROCKFORD, MONITOR_URL, MONITOR_FILE, monitor_identity(), monitor_post() and monitor(). Leave out the approval and brain lines. Then write monitor_report() from the bridge's own state:
def monitor_report():
"""Only public views leave the home base: the collar's public Pulse and public alerts."""
with lock:
pulse = envelope("heart.pulse", pulse_data())
alerts = [envelope("heart.arrhythmia", public_alert(a)) for a in reversed(device["alerts"][-16:])]
seen = (pulse["data"]["status"], sum(a["status"] != "resolved" for a in device["alerts"]))
return {"pulse": pulse, "alerts": alerts}, seen
Start the monitor() thread in main() only when HEART_MONITOR_URL is set, and only after the collar's card line has arrived. In the hello, send the collar's template (from the card line), not the bridge's.
Change the bridge's escalation line, print(f"HEART escalate: ..."), to also light the home base's LED and send a notification to the owner's app. Never to the collar's LED or buzzer.
4. Build and install#
4.1 Try it on a computer first#
The template's own instructions, unchanged:
cc -std=c99 -Wall -o heart heart_line.c port_linux.c
./heart | python3 heart_bridge.py --stdin
curl http://127.0.0.1:43278/heart/v1/pulse
touch fault.flag # within 30 s: system.main-function-lost; delete it to resolve
4.2 The collar firmware#
The nRF9160 is built with the nRF Connect SDK. Put the HEART files in your application's src/ folder and add them to its CMakeLists.txt:
target_sources(app PRIVATE src/heart_line.c src/heart_port_collar.c)
west build -b <your board> # the non-secure build of your nRF9160 board
west flash
The core needs no Kconfig options: no heap, no stdio, no floating point.
4.3 The home base#
On Raspberry Pi OS, as root:
useradd --system --home-dir /var/lib/heart-collar --shell /usr/sbin/nologin heart
install -d /opt/heart/collar
install -m 0755 heart_bridge.py collar-relay /opt/heart/collar/
/etc/systemd/system/heart-collar.service:
[Unit]
Description=HEART for the dog collar (HEART Line bridge and HEART Hub reporting)
After=network-online.target bluetooth.service
Wants=network-online.target
[Service]
ExecStart=/bin/sh -c '/opt/heart/collar/collar-relay | /usr/bin/python3 /opt/heart/collar/heart_bridge.py --stdin'
User=heart
Group=heart
SupplementaryGroups=bluetooth gpio
WorkingDirectory=/var/lib/heart-collar
StateDirectory=heart-collar
Environment=HEART_MONITOR_URL=https://heartcortex.ai
Restart=on-failure
RestartSec=5
Nice=10
MemoryMax=64M
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
- It runs as
heart, a system user with no login, and writes only to/var/lib/heart-collar. That is whereheart-monitor.json(the device ID, claim code and key, mode 0600) is kept. - Reporting is on because
HEART_MONITOR_URLis set. Remove the line to turn it off. Use your own monitor site's address if you run one. - The
bluetoothgroup lets the relay talk to BlueZ on Raspberry Pi OS;gpiolets it light the LED. Check both on your image.
systemctl daemon-reload
systemctl enable --now heart-collar
journalctl -u heart-collar -f
At first start the log shows the HEART Hub line once:
HEART Hub: register this device at https://heartcortex.ai/register?id=5c1e9a07d3b2f468 with claim code AB3CD-EF4GH-JK5LM-NP6QR
Then check it on the home base:
curl http://127.0.0.1:43278/.well-known/heart-card.json
curl http://127.0.0.1:43278/heart/v1/pulse
Until the collar's first card line arrives, the bridge answers 503 system-unreachable. The bridge serves plain HTTP on loopback only. For the owner's app on the home network, put a TLS proxy in front of it, with the home base's own certificate.
5. On the production line#
For each collar and its home base, packed together:
- Flash the collar with the release firmware, and turn on the nRF9160's access-port protection, so the firmware and its keys cannot be read back.
- Pair the collar with its home base over Bluetooth, on the jig.
- Flash the home base's card from your release image. The image must not contain
/var/lib/heart-collar/heart-monitor.json, or every unit would share one identity. - Start the home base once on the jig. It makes its device ID, claim code and key. Read them from
journalctl -u heart-collar, and print the label for under the home base and a card in the box: the claim code, and a QR code ofhttps://heartcortex.ai/register?id=<ID>. For a product, replace the log line with your jig's label printer (theCHANGEatmonitor_identity()). - Check that the collar reaches it:
curl http://127.0.0.1:43278/heart/v1/pulseon the home base shows healthy, with the collar's sequence counting up. - Run the bench tests of Section 7 on a sample from each batch.
6. What the owner sees#
- Setup. The maker's app pairs the phone, joins the home base to the Wi-Fi, and sets the safe zone, as before.
- Registration. The owner scans the QR code on the card in the box, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows Healthy. It is private until the owner shares it. See the owner's manual.
- Every day. Nothing, except charging the collar every few weeks (Section 8).
- When something goes wrong:
| Real event | The HEART notices | The owner sees |
|---|---|---|
| The cell ages and runs down | power.battery-low below 15 % | A notification in the app; the page shows Degraded |
| A walk goes into thick woods | sensors.no-reading: 3 failed fixes and 150 s with no position, away from home | Degraded, with the next report; resolved out of the woods |
| The dog is left in a parked car in the sun | Above 40 Cel the firmware sends a private heat.risk warning to the app. Above 45 Cel, HEART raises performance.too-hot | The urgent warning in the app first. The page shows only performance.too-hot: never where the car is |
| The buckle opens and the collar lies on the grass | system.main-function-degraded after about 30 minutes still and cool | "The collar may have slipped off the dog": a warning, not a certainty |
| The accelerometer freezes | sensors.stuck, while GNSS shows the collar moving | Degraded; restart the collar, or send it for service |
| A basement, out of reach of the home base and the cell network | connectivity.link-lost, on the collar | Nothing until the link is back, then the alert and its end together |
| Away from home with no fix and no link, or the battery at 3 % | system.main-function-lost on the collar, sent at once if any link works | Failing. With no link, nothing can arrive: the next row is the warning |
| The collar's battery runs flat, or it is lost in a lake | Nothing can be sent | After 30 minutes the bridge marks the collar unknown: the page shows Unknown |
| The home base is unplugged, or the home internet is down | Nothing can be sent | After 30 minutes (three intervals of 600 s) the page shows Not heard from, and one email |
| The dog slips through the gate | Nothing: not a fault | The maker's app shows a private zone.left event. Never on the HEART Hub page |
- Changing the LED or the buzzer. In this build, HEART offers no changes: the bridge answers
501to every one. The owner changes the LED, the buzzer and lost mode in the maker's app, as before, and the collar's welfare guard still applies (the buzzer never louder than 50 %, never longer than 5 s, never sooner than 60 s after the last). Section 9 shows how HEART Full would gate them with the owner's passkey.
7. Test it on the bench#
Cause every fault for real, and check that the right fault code appears on the home base's API and on the HEART Hub page. Never on a dog. Away-from-home tests need the home base switched off, or the collar out of its range.
| Fault | How to cause it on the bench | Expect |
|---|---|---|
| Battery low | Run the collar from a battery simulator or bench supply, and lower it until the fuel gauge reads under 15 % (about 3.6 V on many cells: check your cell's curve) | power.battery-low |
| Too hot | Disconnect the cell and run the collar from a bench supply. Warm only the TMP102 with a hot-air tool on a low setting, past 45 Cel | performance.too-hot |
| No fix | Home base off. Disconnect the GNSS antenna; leave the LTE-M antenna on | sensors.no-reading after about 150 s |
| Link lost | Home base off, and the SIM suspended in your operator's portal (or the collar in a shielded box) | connectivity.link-lost, shown when the link is back |
| Main job lost | Home base off, GNSS antenna off and the SIM suspended | system.main-function-lost, sent as soon as any link returns |
| Slipped off | Take the collar off and leave it on a cool floor for 30 minutes | system.main-function-degraded |
| Accelerometer stuck | No safe way to freeze it for real. Cut its supply on a test board instead: the reads fail | Whatever your firmware does with a failed read. The guide's rule needs a frozen value, so test it in a test build that repeats the last reading |
| Collar silent | Pull the collar's cell (power off first) | Unknown after 30 minutes |
| Home base silent | Unplug the home base | Not heard from after 30 minutes, and one email |
| No change through HEART | curl -X POST http://127.0.0.1:43278/heart/v1/capabilities/collar.buzzer/invoke | 501 not-implemented; the buzzer stays silent |
| Privacy | Read every message the home base sends to HEART Hub (a proxy on the bench, or your own monitor site) | Status, sequence, interval and fault codes only. No position, distance, zone event or activity |
Then run the HEART-Rating client against the home base, and claim only what passed.
8. Battery life: what to expect#
The figures below are the simulator's average currents, not measurements of any module. Measure your own collar with a power analyser, such as Nordic's Power Profiler Kit II, before you print a battery life on the box.
| Mode | Average current in the simulator | On a full 900 mAh cell |
|---|---|---|
| At home: Bluetooth to the home base, GNSS off | 0.35 + 0.15 = 0.50 mA | 900 ÷ 0.50 = 1800 h, about 75 days |
| Away: a fix every 60 s, an LTE-M report every 10 min | 0.35 + 2.5 + 0.7 = 3.55 mA | 900 ÷ 3.55 = 254 h, about 10½ days |
| Lost mode: GNSS tracking and a report every 30 s | 0.35 + 30 + 12 = 42.35 mA | 900 ÷ 42.35 = 21 h |
| Lost mode with the LED steady | 42.35 + 5 = 47.35 mA | 900 ÷ 47.35 = 19 h |
0.35 mA is the microcontroller, accelerometer and Bluetooth. A typical day of 21 hours at home and 3 hours of walks uses 21 × 0.50 + 3 × 3.55 = 10.5 + 10.65 = 21.15 mAh. That is 900 ÷ 21.15 = 43 days on a full cell, or 34 days if you count only 80 % of it as usable (720 ÷ 21.15). In lost mode, a cell at 15 % (135 mAh) lasts 135 ÷ 42.35 = about 3 hours: that is why power.battery-low matters.
What the HEART itself costs. The checks run on wakes the collar makes anyway. Each report carries about 45 more bytes (one Pulse line), and an alert line is about 75 bytes. The only extra radio use is an early report for a critical fault. In the simulator's model one LTE-M report costs about 0.7 mA × 600 s = 420 mAs = 0.12 mAh, so even ten early reports in a day add about 1.2 mAh, under 6 % of a typical day.
Sleep. The simulator can also put the collar into deep sleep (0.012 mA) through the HEART API. This build does not offer that: the collar's own power management decides. If you add it, keep the simulator's refusals: never sleep while system.main-function-lost is open, while lost mode is on, while the dog is outside the safe zone, or after a heat.risk warning. A sleeping collar cannot notice anything.
9. Going further#
- The LED and the buzzer through HEART. They are physical: they act on a dog. They need HEART Full: a gate that waits for the owner's proof of presence, a passkey on the phone over the exact Action digest.
heartdhas the gate, Actions and passkey checks (the[presence]section of its configuration). It would run on the maker's server and send the approved change down the collar's existing link. The welfare guard stays in the collar's firmware, and checks again. Lost mode is not physical, so it could run at once with the owner key.heartd's configuration has no HEART Hub section, so keep the reporting of Section 3.6. - An ESP32 home base. The guide notes that the ESP32 Full starter suits a home base, not the collar. It would give the home base its own HEART, but there is no ESP32 version of the bridge to serve the collar's lines. That would be new code.
- Why, with a confidence (A2). At HEART Connected, on the home base or server: "no fix for 10 minutes; the last fix had 4 satellites and HDOP 4.2: probably under thick trees". That needs more than HEART Line carries today.
- Water getting in. Part 3 has no fault code for it. A maker who can detect it would use a product code under its own domain, such as
com.example.pets.water-ingress.