# ESP32 Rust — runnable documentation for build / flash / monitor.
# See docs/setup.md for one-time setup (espup, brew deps, Python 3.12 shim).
#
# Quick start (new device):
#   make provisioning.toml   # one-time: copy template
#   $EDITOR provisioning.toml  # fill in wifi creds, trust identities
#   make bootstrap           # full reflash + write NVS partition
#   make monitor             # watch it boot + connect

PORT ?= /dev/cu.usbserial-0001
BIN  := target/xtensa-esp32-espidf/release/esp32-blinky

# Host triple for building host-side tools (publisher, provision).
HOST_TRIPLE := $(shell rustc -vV | sed -n 's/^host: //p')

# App-only firmware binary (no bootloader / partition table) suitable for
# pushing as the OTA artifact layer.
FW_BIN := target/firmware.bin

# Generated NVS partition image written by `tools/provision/`.
NVS_BIN := target/nvs.bin

# OCI artifact destination.
OCI_REPO ?= ghcr.io/imjasonh/esp32

# Username for cosign's registry auth (its own auth, separate from the
# OIDC identity used to sign). Override with COSIGN_REGISTRY_USERNAME=...
# in CI to use the workflow's actor.
COSIGN_REGISTRY_USERNAME ?= imjasonh

# Short git SHA, baked into the firmware via env!() (see build.rs + main.rs)
# and used as the secondary OCI tag on publish.
GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
export GIT_SHA

# Toolchain paths installed by `espup install --targets esp32`. The
# wildcards absorb the date-stamped subdirectory names so espup upgrades
# don't require editing this file.
LIBCLANG_PATH  := $(firstword $(wildcard $(HOME)/.rustup/toolchains/esp/xtensa-esp32-elf-clang/*/esp-clang/lib))
XTENSA_GCC_BIN := $(firstword $(wildcard $(HOME)/.rustup/toolchains/esp/xtensa-esp-elf/*/xtensa-esp-elf/bin))

# A throwaway directory containing `python3` -> uv-managed Python 3.12.
# Put on PATH ahead of system python so embuild bootstraps the IDF venv
# against 3.12 instead of Apple's /usr/bin/python3 (3.9). Created on demand
# by the `ensure-python-shim` target — not checked in.
PYTHON_SHIM := $(CURDIR)/.embuild/python-shim

# Exported to every recipe shell. Replaces the old export-esp.sh script.
export LIBCLANG_PATH
export PATH := $(PYTHON_SHIM):$(XTENSA_GCC_BIN):$(PATH)

.PHONY: help build flash flash-all monitor run clean ensure-python-shim \
        save-image publish pull-verify check-gh-env \
        provision provision-build bootstrap \
        antithesis

help:
	@echo "Targets:"
	@echo "  make build         Compile firmware"
	@echo "  make flash         Build (if needed) and flash app to $(PORT)"
	@echo "  make flash-all     Erase + write bootloader, partition table, app"
	@echo "  make monitor       Open serial monitor on $(PORT)  (Ctrl+C to exit)"
	@echo "  make run           Build + flash + monitor"
	@echo "  make clean         cargo clean"
	@echo "  make save-image    Convert ELF -> app-only .bin (target/firmware.bin)"
	@echo "  make publish       Build, save-image, push OCI artifact to OCI_REPO"
	@echo "  make pull-verify   Pull from OCI_REPO and check layer matches local .bin"
	@echo ""
	@echo "  make provision     Build NVS image from provisioning.toml + flash to device"
	@echo "  make bootstrap     flash-all + provision (new device setup)"
	@echo ""
	@echo "  make antithesis    Run Antithesis property tests (host-side)"
	@echo ""
	@echo "Override the port: make flash PORT=/dev/cu.usbserial-XXXX"

build: ensure-python-shim sdkconfig.defaults
	# `+esp` selects the espup-installed toolchain explicitly. We don't
	# use `rust-toolchain.toml` for the firmware crate anymore because
	# Dependabot's runner doesn't have the esp toolchain installed and
	# `cargo update` chokes on the override file. Host-side tools have
	# their own `rust-toolchain.toml` pinning stable.
	cargo +esp build --release

# sdkconfig.defaults is generated from the .in template so we can substitute
# the absolute project path into CONFIG_PARTITION_TABLE_CUSTOM_FILENAME
# (IDF resolves it relative to the embuild synthetic project, not our repo
# root, so a bare "partitions.csv" doesn't work). Gitignored.
sdkconfig.defaults: sdkconfig.defaults.in partitions.csv
	@sed 's|@PROJECT_DIR@|$(CURDIR)|g' sdkconfig.defaults.in > sdkconfig.defaults

# Idempotent: creates $(PYTHON_SHIM) and (re)points python3 at the current
# uv-managed 3.12 interpreter. Auto-installs Python 3.12 via uv if missing.
#
# Important: nuke any pre-existing shim symlinks first. If the shim dir was
# restored from a CI cache, the symlinks may target a path that no longer
# exists on this runner, OR may have been auto-resolved by `uv python find`
# back to the shim itself (self-reference) producing ELOOP. Recreating
# from scratch each run avoids both.
ensure-python-shim:
	@command -v uv >/dev/null || { echo "ERROR: uv not installed. See docs/setup.md."; exit 1; }
	@mkdir -p $(PYTHON_SHIM)
	@rm -f $(PYTHON_SHIM)/python $(PYTHON_SHIM)/python3
	@uv python find 3.12 >/dev/null 2>&1 || uv python install 3.12
	@PY=$$(uv python find 3.12) && \
	    ln -sf "$$PY" $(PYTHON_SHIM)/python3 && \
	    ln -sf "$$PY" $(PYTHON_SHIM)/python

# Pass --partition-table on every flash, even app-only. Without it,
# espflash 4.x writes a *default* (factory-only) partition table over
# our OTA layout — silently bricking OTA on the device. Re-passing
# partitions.csv is a no-op when it already matches what's on flash.
flash: build
	espflash flash --port $(PORT) --partition-table partitions.csv $(BIN)

# Full reflash: erase entire flash, then write bootloader + partition table
# + app. Use this when the partition table changes, or as the recovery
# path if both OTA slots are bad and anti-bricking failed. After erase,
# otadata is empty and the bootloader picks ota_0 by default. NVS is also
# wiped, so the device will boot strict-inert until `make provision`.
BOOTLOADER := $(firstword $(wildcard target/xtensa-esp32-espidf/release/build/esp-idf-sys-*/out/build/bootloader/bootloader.bin))

flash-all: build
	@test -n "$(BOOTLOADER)" || { echo "ERROR: bootloader.bin not found under target/. Run 'make build' first."; exit 1; }
	espflash erase-flash --port $(PORT)
	espflash flash --port $(PORT) \
	    --bootloader $(BOOTLOADER) \
	    --partition-table partitions.csv \
	    $(BIN)

monitor:
	espflash monitor --port $(PORT) $(if $(MAKE_TERMOUT),,--non-interactive)

run: build
	espflash flash --port $(PORT) --partition-table partitions.csv --monitor $(BIN)

clean:
	cargo clean

# Build NVS partition image from provisioning.toml AND flash it to the
# device over USB. Requires `make build` to have run at least once so
# embuild has cloned ESP-IDF (needed for nvs_partition_gen.py).
provision: provisioning.toml build
	cd tools/provision && cargo run --release --target $(HOST_TRIPLE) -- \
	    --config $(CURDIR)/provisioning.toml \
	    --out $(CURDIR)/$(NVS_BIN) \
	    --nvs-gen $(NVS_GEN_PY) \
	    --python $(NVS_GEN_PYTHON) \
	    --port $(PORT) \
	    --flash

# Build the NVS image without flashing (useful for inspection / CI).
provision-build: provisioning.toml build
	cd tools/provision && cargo run --release --target $(HOST_TRIPLE) -- \
	    --config $(CURDIR)/provisioning.toml \
	    --out $(CURDIR)/$(NVS_BIN) \
	    --nvs-gen $(NVS_GEN_PY) \
	    --python $(NVS_GEN_PYTHON)

# ESP-IDF's NVS partition generator + the python venv to run it in.
# Both come from .embuild after `make build` has cloned ESP-IDF.
NVS_GEN_PY     := $(CURDIR)/.embuild/espressif/esp-idf/v5.2.2/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py
NVS_GEN_PYTHON := $(CURDIR)/.embuild/espressif/python_env/idf5.2_py3.12_env/bin/python

# Common new-device setup: full reflash + provision NVS. After this the
# device should boot, connect to Wi-Fi, and start polling for OTAs.
bootstrap: flash-all provision

# If provisioning.toml exists, this target is up-to-date and the recipe
# doesn't run. If absent, copy the template, tell the user to edit it,
# and fail so they can't accidentally provision with placeholder values.
provisioning.toml:
	@cp provisioning.toml.example provisioning.toml
	@echo ""
	@echo "Created provisioning.toml from provisioning.toml.example."
	@echo "Edit it with your real Wi-Fi creds + trust identities, then re-run."
	@echo ""
	@exit 1

# Convert the firmware ELF into an app-only .bin (no bootloader / partition
# table). This is the format the OTA writer expects on the device, and what
# we wrap as an OCI artifact layer for OTA distribution.
save-image: $(FW_BIN)

$(FW_BIN): build
	espflash save-image --chip esp32 $(BIN) $(FW_BIN)
	@echo "Wrote $(FW_BIN) ($$(wc -c < $(FW_BIN)) bytes)"

# Build firmware -> save-image -> push OCI artifact to OCI_REPO. Tags
# pushed: :latest and :sha-<short>. Requires GH_TOKEN in gh.env.
publish: $(FW_BIN) check-gh-env
	@set -e; \
	    . ./gh.env; \
	    DIGEST=$$(cd tools/publisher && cargo run --release --target $(HOST_TRIPLE) -- push \
	        --bin $(CURDIR)/$(FW_BIN) \
	        --repo $(OCI_REPO) \
	        --git-sha $(GIT_SHA) | grep '^digest:' | head -1 | cut -d' ' -f2); \
	    test -n "$$DIGEST" || { echo "ERROR: publisher did not emit a 'digest:' line on stdout"; exit 1; }; \
	    echo ">>> Signing $(OCI_REPO)@$$DIGEST with cosign (keyless OIDC)"; \
	    COSIGN_REGISTRY_USERNAME=$(COSIGN_REGISTRY_USERNAME) COSIGN_REGISTRY_PASSWORD="$$GH_TOKEN" \
	        cosign sign --yes $(OCI_REPO)@$$DIGEST

# Pull the latest artifact from OCI_REPO and verify its layer SHA matches
# our locally-built firmware. Confirms the round trip works.
pull-verify: $(FW_BIN) check-gh-env
	. ./gh.env && cd tools/publisher && cargo run --release --target $(HOST_TRIPLE) -- pull-verify \
	    --repo $(OCI_REPO) \
	    --tag latest \
	    --bin $(CURDIR)/$(FW_BIN) \
	    --auth

# Property tests for the firmware's pure-Rust algorithms (DSSE PAE
# encoding, OTA backoff/jitter math). Lives in tools/antithesis/ and
# uses the Antithesis SDK so the same test binary runs locally, in CI,
# and (eventually) inside the Antithesis simulator. A failed invariant
# panics, so a non-zero exit fails the build.
antithesis:
	cd tools/antithesis && cargo run --release --target $(HOST_TRIPLE)

check-gh-env:
	@test -f gh.env || { \
	    echo "ERROR: gh.env not found."; \
	    echo "Create it with:"; \
	    echo "    echo 'export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' > gh.env"; \
	    echo "See README.md for how to mint a classic PAT with write:packages."; \
	    exit 1; \
	}
