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:
parent
e525bb2bbf
commit
01c8c2da75
5 changed files with 70 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue