1
0
Fork 0
mirror of https://github.com/imjasonh/esp32 synced 2026-07-06 23:52:24 +00:00

provision OTA repo/tag/poll_secs; tag logs+metrics with fw_version

Two related additions for fleet observability + tunability:

1. `[ota]` block in provisioning.toml. Optional, individually-optional
   fields: `repo`, `tag`, `poll_secs`. Writes to the existing `ota` NVS
   namespace (key names match src/ota.rs's NVS_REPO / NVS_TAG /
   NVS_POLL_SECS). Missing keys leave the firmware on its compile-time
   defaults (ghcr.io/imjasonh/esp32:latest, 60 s poll).

2. `fw_version` (the GIT_SHA baked in at build time) on every log
   entry and every metric series, so behaviour can be correlated with
   a release across the fleet.
   - cloud_log: jsonPayload.fw_version on every entry.
   - metrics: metric.labels.fw_version on every TimeSeries (resource
     labels can't carry it — `generic_node` has a fixed schema; metric
     labels let queries split heap, rssi, etc. by fw).

Both threaded from main.rs's existing FW_VERSION constant; no new
config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Hall 2026-05-02 23:56:26 -04:00
parent e525bb2bbf
commit 01c8c2da75
5 changed files with 70 additions and 7 deletions

View file

@ -40,3 +40,12 @@ issuer = "https://token.actions.githubusercontent.com"
# sa_key_pem = "gcp-sa-key.pem" # path to the PKCS#8 RSA private key PEM
# min_severity = "info" # trace / debug / info / warn / error
# metrics_interval_secs = 300 # 0 = metrics off (logs still ship); default 300
# Optional. If absent, OTA loop uses compile-time defaults
# (ghcr.io/imjasonh/esp32:latest, 60 s poll). Each field is
# individually optional too — only present ones override.
#
# [ota]
# repo = "ghcr.io/imjasonh/esp32" # default
# tag = "latest" # default
# poll_secs = 600 # default 60; bump for steady-state fleets

View file

@ -322,6 +322,7 @@ impl FieldCapture {
/// to Cloud Logging.
pub fn run(
cfg: GcpConfig,
fw_version: &'static str,
auth: Arc<TokenProvider>,
queue: LogQueue,
short_https: ShortHttpsLock,
@ -384,7 +385,7 @@ pub fn run(
}
};
match post_batch(&log_name, &cfg.project_id, &mac, &bearer, &batch) {
match post_batch(&log_name, &cfg.project_id, &mac, fw_version, &bearer, &batch) {
Ok(()) => {
tracing::debug!(
entries = batch.len(),
@ -445,6 +446,7 @@ fn post_batch(
log_name: &str,
project_id: &str,
mac: &str,
fw_version: &str,
bearer: &str,
batch: &[LogEntry],
) -> Result<()> {
@ -452,7 +454,7 @@ fn post_batch(
.iter()
.map(|e| Entry {
severity: severity_str(e.severity),
json_payload: build_payload(e),
json_payload: build_payload(e, fw_version),
timestamp: e.timestamp_unix_secs.and_then(unix_to_rfc3339),
})
.collect();
@ -493,7 +495,7 @@ fn severity_str(level: Level) -> &'static str {
}
}
fn build_payload(e: &LogEntry) -> serde_json::Value {
fn build_payload(e: &LogEntry, fw_version: &str) -> serde_json::Value {
let mut map = e.fields.clone();
map.insert(
"message".to_string(),
@ -503,6 +505,13 @@ fn build_payload(e: &LogEntry) -> serde_json::Value {
"module".to_string(),
serde_json::Value::String(e.target.clone()),
);
// Tag every entry with the firmware git SHA so behaviour can be
// bucketed by release in Cloud Logging
// (`jsonPayload.fw_version="..."`). Adds ~15 bytes/entry.
map.insert(
"fw_version".to_string(),
serde_json::Value::String(fw_version.to_string()),
);
if e.dropped_before > 0 {
map.insert(
"dropped_before".to_string(),

View file

@ -182,7 +182,7 @@ fn main() -> Result<()> {
std::thread::Builder::new()
// RSA signing + HTTPS POST. 32 KB matches the OTA loop budget.
.stack_size(32 * 1024)
.spawn(move || cloud_log::run(cl_cfg, cl_auth, cl_queue, cl_lock))
.spawn(move || cloud_log::run(cl_cfg, FW_VERSION, cl_auth, cl_queue, cl_lock))
.expect("spawn cloud_log thread");
if cfg.metrics_interval_secs > 0 {
@ -194,7 +194,7 @@ fn main() -> Result<()> {
// No crypto on hot path (token cached via auth).
// Mainly HTTPS POST + serde_json. 16 KB sufficient.
.stack_size(16 * 1024)
.spawn(move || metrics::run(m_cfg, m_auth, m_queue, m_lock))
.spawn(move || metrics::run(m_cfg, FW_VERSION, m_auth, m_queue, m_lock))
.expect("spawn metrics thread");
}
}

View file

@ -53,6 +53,7 @@ fn read_handle(slot: &AtomicUsize) -> Option<esp_idf_svc::sys::TaskHandle_t> {
pub fn run(
cfg: GcpConfig,
fw_version: &'static str,
auth: Arc<TokenProvider>,
queue: LogQueue,
short_https: ShortHttpsLock,
@ -111,7 +112,7 @@ pub fn run(
}
};
match post_time_series(&url, &cfg.project_id, &mac, &bearer, &snapshot) {
match post_time_series(&url, &cfg.project_id, &mac, fw_version, &bearer, &snapshot) {
Ok(()) => {
tracing::debug!(
series = snapshot.series_count(),
@ -302,6 +303,7 @@ fn post_time_series(
url: &str,
project_id: &str,
mac: &str,
fw_version: &str,
bearer: &str,
snapshot: &Snapshot,
) -> Result<()> {
@ -323,7 +325,18 @@ fn post_time_series(
};
let mut series = Vec::with_capacity(snapshot.series_count());
let mut push = |name: &str, labels: serde_json::Map<String, serde_json::Value>, value: i64| {
// `fw_version` rides on every metric as a label so Cloud Monitoring
// can split / group by release. Resource labels can't carry it
// because `generic_node` has a fixed schema; metric labels are
// per-series and let queries split heap, rssi, etc. by fw.
let mut push = |name: &str,
extra_labels: serde_json::Map<String, serde_json::Value>,
value: i64| {
let mut labels = extra_labels;
labels.insert(
"fw_version".into(),
serde_json::Value::String(fw_version.to_string()),
);
series.push(TimeSeries {
metric: Metric {
type_: format!("{}/{}", METRIC_PREFIX, name),

View file

@ -57,6 +57,10 @@ struct ProvisioningConfig {
trust: TrustConfig,
/// Optional. If absent, the device boots with serial-only logging.
gcp: Option<GcpConfig>,
/// Optional. If absent, OTA loop uses its compile-time defaults
/// (`ghcr.io/imjasonh/esp32:latest`, 60 s poll). All fields are
/// individually optional too; only present ones override defaults.
ota: Option<OtaProvisioningConfig>,
}
#[derive(Deserialize, Debug)]
@ -105,6 +109,17 @@ fn default_metrics_interval() -> u32 {
300
}
#[derive(Deserialize, Debug, Default)]
struct OtaProvisioningConfig {
/// Override `ghcr.io/<owner>/<name>` repo. Default
/// `ghcr.io/imjasonh/esp32`.
repo: Option<String>,
/// Override the image tag. Default `latest`.
tag: Option<String>,
/// Override the OTA poll interval in seconds. Default 60.
poll_secs: Option<u32>,
}
fn severity_to_u8(s: &str) -> Result<u8> {
match s.to_ascii_lowercase().as_str() {
"trace" => Ok(0),
@ -319,6 +334,23 @@ fn write_csv(
])?;
}
// ota namespace (optional). Each field is independently optional;
// missing keys leave the firmware on its compile-time defaults
// (`ghcr.io/imjasonh/esp32:latest`, 60 s poll). Key names match
// src/ota.rs's NVS_REPO / NVS_TAG / NVS_POLL_SECS constants.
if let Some(ota) = &cfg.ota {
wtr.write_record(&["ota", "namespace", "", ""])?;
if let Some(repo) = &ota.repo {
wtr.write_record(&["repo", "data", "string", repo])?;
}
if let Some(tag) = &ota.tag {
wtr.write_record(&["tag", "data", "string", tag])?;
}
if let Some(poll_secs) = ota.poll_secs {
wtr.write_record(&["poll_secs", "data", "u32", &poll_secs.to_string()])?;
}
}
wtr.flush()?;
Ok(())
}