Zephyr RTOS module: the on-device client library for PidgeIoT. It runs on
the physical asset/gateway and talks to the dovecote edge backend over a
wire protocol shared with the capsules models in the main PidgeIoT
monorepo. This repo has its own git history — it's a standalone Zephyr
module, not a workspace member of the monorepo.
Sample applications that build and exercise this module live in the sibling
pigeon-examples
repo.
Early scaffold. pigeon_init() is implemented; pigeon_set_shadow_param()
is declared in pigeon.h but has no body yet. src/pigeon_coap.c and
src/pigeon_https.c (the actual transports) don't exist yet — pigeon.h's
data structures and pigeon_init() work today regardless, since
CMakeLists.txt compiles pigeon_core.c unconditionally.
include/pigeon.h mirrors the wire shapes in capsules::Connector /
HttpsConfig / CoapConfig / PigeonShadow / PigeonShadowUpdateRequest,
so device code can build config and shadow payloads that stay compatible
with dovecote:
struct pigeon_config—device_id(also the JWT audience) plus astruct pigeon_connector.struct pigeon_connector— atype(PIGEON_CONNECTOR_HTTPS/PIGEON_CONNECTOR_COAP) plusstruct pigeon_coap_config(tls_psk_identity/tls_psk_secret, only consulted for the CoAP connector).endpoint/tokenaren't struct fields — they're build-timeCONFIG_PIGEON_ENDPOINT/CONFIG_PIGEON_TOKENKconfig strings, since the connector type is already a build-time choice (see Kconfig below). Note: the CoAP connector speaks CoAP-over-TLS/TCP (RFC 8323coaps+tcp://), not the usual CoAP-over-DTLS/UDP, since this device stack has no UDP support yet — this is ahead ofdovecote, which still only servescoaps://(UDP/DTLS). SeeCLAUDE.md's "Known wire-compat gap" note before assuming the two sides can talk to each other.struct pigeon_shadow_doc—target_version/current_versioncounters plus raw JSONtarget_config/current_configtext, as returned byGET /pigeon/shadow/get.struct pigeon_shadow_update_request— the body forPOST /pigeon/shadow/update.
CONFIG_PIGEON_FOTA (off by default, zephyr/Kconfig) adds a device-driven
firmware update path on top of the shadow sync above: pigeon.h declares
struct pigeon_fota_info—version/size/sha256(64 lowercase hex chars), the JSON decode target for target_config's app-definedfirmwaresub-object (mirrorsdovecote's shadow-driven FOTA route — seeCLAUDE.md). Like the rest oftarget_config, this key is opaque topigeon_shadow_get(); the app decodes it itself, same aslog/telemetry_interval/reboot.pigeon_fota_update_available(info)— true wheninfo->versiondiffers from the build-timeCONFIG_PIGEON_FOTA_CURRENT_VERSIONstring.pigeon_fota_apply(info)— chunked, device-authed HTTP Range GETs against<CONFIG_PIGEON_ENDPOINT>/firmware(CONFIG_PIGEON_FOTA_CHUNK_SIZEbytes at a time, HTTPS connector only), writing straight into MCUboot's secondary slot via Zephyr'sdfu_targetas each chunk arrives — the image is never held whole in RAM. Verifies the downloaded byte count and a streamed sha256 againstinfobefore scheduling a one-time MCUboot test-swap. Does not reboot: on success the caller must report its shadowcurrent_configback (pigeon_shadow_report()) so the shadow converges before tearing down connectivity and callingsys_reboot()itself — same convention as the existing"reboot": trueshadow command. On any failure (transport, size/hash mismatch, flash write) the secondary slot is left un-schedulable and the running image is untouched.pigeon_fota_confirm_boot()— call once per boot after establishing the device is healthy (e.g. after a successfulpigeon_shadow_get()); a no-op once already confirmed. Skipping this is what makes MCUboot's test-swap fallback work: an image that's staged but never confirmed reverts back to the previous slot on the next reset, so a bad update self-heals without any server-side intervention.
Three layers, smallest scope first.
Within one pigeon_fota_apply() call, a failed chunk is retried at the
same offset instead of ending the transfer
(CONFIG_PIGEON_FOTA_CHUNK_RETRIES), and an HTTP 429 is waited out on the
server's own Retry-After against a separate budget
(CONFIG_PIGEON_FOTA_RATE_LIMIT_RETRIES) — a rate limit is the server
pacing the download, not failing it, and must not spend the tolerance
reserved for real errors. Both counters are consecutive at one offset and
reset on every chunk that lands, so a long download over a flaky link is
never penalized for its length. Delays are clamped by
CONFIG_PIGEON_FOTA_RETRY_AFTER_MAX_SEC.
Across calls and reboots, CONFIG_PIGEON_FOTA_RESUME (on by default
wherever a settings backend exists) makes an interrupted
pigeon_fota_apply() resumable: the bytes already flushed to the secondary
slot are re-hashed from flash on the next call and only the remainder is
Range-requested, instead of restarting from byte 0. Requires the app to
provide a settings backend (CONFIG_SETTINGS + e.g. CONFIG_NVS) — without
one the symbol simply stays off; see the option's Kconfig help for the
invalidation rules (version change, failed verify, untrusted state). The
reconcile/persistence logic has a native_sim unit suite under
tests/fota_resume (build/run instructions in its src/main.c header).
Across the whole campaign, CONFIG_PIGEON_FOTA_ATTEMPT_BUDGET (opt-in)
bounds how many times one firmware target is chased, so an image that
downloads and verifies cleanly but then boot-loops until MCUboot reverts it
cannot re-download itself forever. The count is bound to the shadow
target_version that named the firmware rather than to the version string
alone, which is what keeps it recoverable: an operator who writes the
device's shadow again — same firmware target still in it — reopens the
budget, while nothing the device does by itself can. Call
pigeon_fota_attempt_allowed(info, shadow->target_version) before
pigeon_fota_apply(), and pigeon_fota_attempts_clear() once the offered
version is confirmed to be the one running; both compile to no-ops when the
option is off. Unit suite under tests/fota_attempts.
A resumed continuation that moves the flushed offset forward costs nothing against that budget — resuming is meant to be cheap. One that ends exactly where it began made no progress and is charged like a fresh attempt, so a transfer wedged at one offset still terminates.
Signing key: MCUboot's own image signature check (sysbuild.conf:
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y) is the actual security
boundary for firmware authenticity — pigeon_fota_apply()'s sha256 check
is only an integrity check against transport/flash corruption, not a
signature. With no CONFIG_BOOT_SIGNATURE_KEY_FILE override, MCUboot signs
against its upstream default dev key
(bootloader/mcuboot/root-ec-p256.pem, pulled in by west update) —
that key (and its matching private key) ships in the open-source MCUboot
repo, so anyone can forge a signature against it. Never ship a
production device with the default key: generate a real keypair
(imgtool keygen), point CONFIG_BOOT_SIGNATURE_KEY_FILE at the public
half in the MCUboot child image's own prj.conf
(pigeon-examples/samples/https_init/sysbuild/mcuboot/prj.conf), and keep
the private half off any machine that doesn't need to sign a release
image.
This is a Zephyr module, not a standalone app — CMakeLists.txt
hard-fails if ZEPHYR_BASE isn't set. It must be pulled into a Zephyr
application/workspace, either via a west manifest project entry or
ZEPHYR_EXTRA_MODULES (see pigeon-examples/samples/pigeon_module.cmake
for the latter):
list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/../pigeon)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})CONFIG_PIGEON— menuconfig gate for the connector choice below. Leaving it disabled is fine;pigeon_init()and the data structures work either way, since only the (not-yet-implemented) transport source files are gated behind it.CONFIG_PIGEON_CONNECTOR_COAP/CONFIG_PIGEON_CONNECTOR_HTTPS— mutually exclusive choice, only relevant once a transport is implemented.CONFIG_PIGEON_ENDPOINT/CONFIG_PIGEON_TOKEN— the backend URL and device JWT, required wheneverpigeon_init()is called (checked unconditionally, regardless ofCONFIG_PIGEON).CONFIG_PIGEON_LOG_LEVEL— 0 (none) to 4 (debug), default 3.
AGPLv3 — see LICENSE.