HEART Embedded Systems logoHEART

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.

PlacementA, 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 fromOn 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.
ClaimHEART Basic, Autonomy Level A1. Changing the LED or the buzzer through HEART needs HEART Full (Section 9).
Try it firstThe simulator and the design case study

1. The device we assume#

The collar#

PartAssumed choiceNotes
Cellular and GNSSA 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.
BluetoothA Nordic nRF52832, beside the nRF9160, on a UARTThe nRF9160 has no Bluetooth. This chip talks to the home base.
GNSS antennaA ceramic patch on the top of the collarIt needs open sky
AccelerometerA Bosch BMA400 on I²C, its interrupt line wired to wake the chipResting, walking, running; and whether the collar is on a dog
TemperatureA TI TMP102 on I²C, against the cellA lithium cell must not get too hot
Fuel gaugeA Maxim MAX17048 on I²CReports the charge in % directly
BatteryA 3.7 V lithium-polymer cell, about 900 mAh, with a single-cell charger IC and charging contactsAs in the guide. Section 8 shows what it lasts.
LED and buzzerA small LED and a piezo buzzer, each on a PWM pin through a transistorBoth act on the dog. Driven by the collar's firmware only, with its welfare guard
ButtonA small recessed buttonWakes the collar from deep sleep. HEART does not use it.
CaseSealed, with charging contactsA dog swims and rolls in mud

The home base#

PartAssumed choiceNotes
BoardA Raspberry Pi Zero 2 W (Wi-Fi and Bluetooth), on a 5 V mains adapterRuns Linux, so it can run the bridge, which is Python
Status lightA green LED on GPIO17 (pin 11), through a 330 Ω resistorLights when the collar has a fault, with no internet
NetworkThe home Wi-FiReports to HEART Hub, outbound only
StorageIts microSD cardThe device ID, claim code and key, in /var/lib/heart-collar

Example wiring on the collar#

SignalnRF9160 pin (example)Used by
I²C SDA, SCL (BMA400, TMP102, MAX17048)P0.30, P0.31The collar's firmware, which fills the values HEART checks
BMA400 interrupt 1P0.29Wakes the chip on movement
UART to the nRF52832P0.00 to P0.03The firmware's Bluetooth link
LED (PWM)P0.10The firmware only
Buzzer (PWM)P0.11The firmware only, behind its welfare guard
ButtonP0.12, pull-upWakes 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#

FileWhat you do
heart_line.hChange the lines marked CHANGE (3.2), and add the rule type of 3.3
heart_line.cCopy, and add the function of 3.3
heart_port_collar.cYours: 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 firmwareUnchanged, 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 pulse lines first if the outbox fills. Never drop an alert or resolved line.
  • A critical alert goes at once. If a system.main-function-lost line is waiting, send a report now, if any link works. Warnings wait for the next scheduled report.

3.6 On the home base#

FileWhat you do
heart_bridge.pyCopy. Add HEART Hub reporting (below), and change its escalation line
collar-relayYours: 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 where heart-monitor.json (the device ID, claim code and key, mode 0600) is kept.
  • Reporting is on because HEART_MONITOR_URL is set. Remove the line to turn it off. Use your own monitor site's address if you run one.
  • The bluetooth group lets the relay talk to BlueZ on Raspberry Pi OS; gpio lets 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:

  1. 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.
  2. Pair the collar with its home base over Bluetooth, on the jig.
  3. 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.
  4. 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 of https://heartcortex.ai/register?id=<ID>. For a product, replace the log line with your jig's label printer (the CHANGE at monitor_identity()).
  5. Check that the collar reaches it: curl http://127.0.0.1:43278/heart/v1/pulse on the home base shows healthy, with the collar's sequence counting up.
  6. Run the bench tests of Section 7 on a sample from each batch.

6. What the owner sees#

  1. Setup. The maker's app pairs the phone, joins the home base to the Wi-Fi, and sets the safe zone, as before.
  2. 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.
  3. Every day. Nothing, except charging the collar every few weeks (Section 8).
  4. When something goes wrong:
Real eventThe HEART noticesThe owner sees
The cell ages and runs downpower.battery-low below 15 %A notification in the app; the page shows Degraded
A walk goes into thick woodssensors.no-reading: 3 failed fixes and 150 s with no position, away from homeDegraded, with the next report; resolved out of the woods
The dog is left in a parked car in the sunAbove 40 Cel the firmware sends a private heat.risk warning to the app. Above 45 Cel, HEART raises performance.too-hotThe 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 grasssystem.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 freezessensors.stuck, while GNSS shows the collar movingDegraded; restart the collar, or send it for service
A basement, out of reach of the home base and the cell networkconnectivity.link-lost, on the collarNothing 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 worksFailing. With no link, nothing can arrive: the next row is the warning
The collar's battery runs flat, or it is lost in a lakeNothing can be sentAfter 30 minutes the bridge marks the collar unknown: the page shows Unknown
The home base is unplugged, or the home internet is downNothing can be sentAfter 30 minutes (three intervals of 600 s) the page shows Not heard from, and one email
The dog slips through the gateNothing: not a faultThe maker's app shows a private zone.left event. Never on the HEART Hub page
  1. Changing the LED or the buzzer. In this build, HEART offers no changes: the bridge answers 501 to 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.

FaultHow to cause it on the benchExpect
Battery lowRun 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 hotDisconnect 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 Celperformance.too-hot
No fixHome base off. Disconnect the GNSS antenna; leave the LTE-M antenna onsensors.no-reading after about 150 s
Link lostHome 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 lostHome base off, GNSS antenna off and the SIM suspendedsystem.main-function-lost, sent as soon as any link returns
Slipped offTake the collar off and leave it on a cool floor for 30 minutessystem.main-function-degraded
Accelerometer stuckNo safe way to freeze it for real. Cut its supply on a test board instead: the reads failWhatever 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 silentPull the collar's cell (power off first)Unknown after 30 minutes
Home base silentUnplug the home baseNot heard from after 30 minutes, and one email
No change through HEARTcurl -X POST http://127.0.0.1:43278/heart/v1/capabilities/collar.buzzer/invoke501 not-implemented; the buzzer stays silent
PrivacyRead 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.

ModeAverage current in the simulatorOn a full 900 mAh cell
At home: Bluetooth to the home base, GNSS off0.35 + 0.15 = 0.50 mA900 ÷ 0.50 = 1800 h, about 75 days
Away: a fix every 60 s, an LTE-M report every 10 min0.35 + 2.5 + 0.7 = 3.55 mA900 ÷ 3.55 = 254 h, about 10½ days
Lost mode: GNSS tracking and a report every 30 s0.35 + 30 + 12 = 42.35 mA900 ÷ 42.35 = 21 h
Lost mode with the LED steady42.35 + 5 = 47.35 mA900 ÷ 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. heartd has 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.