diff --git a/crates/ocpp-cp/src/lib.rs b/crates/ocpp-cp/src/lib.rs index 2743f15..aeb0a1e 100644 --- a/crates/ocpp-cp/src/lib.rs +++ b/crates/ocpp-cp/src/lib.rs @@ -8740,18 +8740,20 @@ impl ChargePoint { MeterValues => self.trigger_v201_meter_values(evse_id).await, TransactionEvent => self.trigger_v201_transaction_event(evse_id).await, FirmwareStatusNotification => self.trigger_v201_firmware_status_notification().await, - // Diagnostics-log-, publish-firmware-, and certificate-signing triggers - // the simulator does not yet originate. `v201_trigger_message_status` - // reports these `NotImplemented`, so the handler never enqueues them; - // this arm keeps the match exhaustive and aligned with that policy. - // (LogStatusNotification → #584, PublishFirmwareStatusNotification → - // #585 will re-report their latest status as FirmwareStatusNotification - // does; the Sign* certificate triggers are a separate, heavier slice.) + PublishFirmwareStatusNotification => { + self.trigger_v201_publish_firmware_status_notification() + .await + } + // Diagnostics-log- and certificate-signing triggers the simulator does + // not yet originate. `v201_trigger_message_status` reports these + // `NotImplemented`, so the handler never enqueues them; this arm keeps + // the match exhaustive and aligned with that policy. (LogStatusNotification + // → #584 will re-report its latest status as the firmware triggers do; + // the Sign* certificate triggers are a separate, heavier slice.) other @ (LogStatusNotification | SignChargingStationCertificate | SignV2GCertificate - | SignCombinedCertificate - | PublishFirmwareStatusNotification) => { + | SignCombinedCertificate) => { warn!("v201 TriggerMessage({other:?}): not implemented by the simulator"); } } @@ -9202,6 +9204,16 @@ impl ChargePoint { location: Option>, request_id: i32, ) { + // Retain this as the station's latest publish status *before* sending, so + // a later `TriggerMessage(PublishFirmwareStatusNotification)` re-reports + // the current status even if this progress CALL failed to transmit — the + // station's notion of "where the publish is" has advanced regardless. Kept + // across the in-flight marker's clear (see + // `V201PublishFirmwareStore::record_reported`), so a settled `Published` + // (with its cached-image `location`) stays reportable. + self.v201_publish_firmwares + .record_reported(status, location.clone(), request_id) + .await; if let Err(e) = self .call(v201_command::v201_publish_firmware_status_notification( status, location, request_id, @@ -9285,6 +9297,53 @@ impl ChargePoint { } } + /// Re-report the station's latest publish-firmware status for a + /// `TriggerMessage` (`requestedMessage = PublishFirmwareStatusNotification`, + /// Issue #585). + /// + /// The publish-to-local-cache twin of + /// [`trigger_v201_firmware_status_notification`](Self::trigger_v201_firmware_status_notification): + /// a CSMS asks for the *current* publish status, and the station answers with a + /// single `PublishFirmwareStatusNotification` carrying the latest status it has + /// reported — without re-running a publish. The snapshot comes from + /// [`V201PublishFirmwareStore::last_reported`](crate::v201_publish_firmware::V201PublishFirmwareStore::last_reported), + /// which every `send_v201_publish_firmware_status` step records: + /// + /// - a station that has never run a `PublishFirmware` reports + /// [`Idle`](PublishFirmwareStatusEnumType::Idle) with `requestId` and + /// `location` omitted; + /// - a publish in progress reports its most recent interim step + /// (`DownloadScheduled` / `Downloading` / `Downloaded`) with the correlating + /// `requestId` and no `location`; + /// - a settled publish reports its terminal + /// [`Published`](PublishFirmwareStatusEnumType::Published) with that + /// `requestId` *and the cached-image `location` URIs* (or a terminal failure) + /// — retained past the in-flight marker's clear. + /// + /// This is a pure snapshot re-report: it starts no publish and leaves the + /// in-flight set untouched. `TriggerMessage` carries no EVSE scope for this + /// station-wide message, so `evse_id` is not a parameter (mirroring the + /// `BootNotification` / firmware arms). Runs on the command-consumer task (off + /// the inbound-CALL path), so the `TriggerMessage` CALLRESULT is flushed before + /// this CALL and the receive loop never re-enters itself. + async fn trigger_v201_publish_firmware_status_notification(&self) { + let report = self.v201_publish_firmwares.last_reported().await; + if let Err(e) = self + .call(v201_command::v201_publish_firmware_status_report( + report.status, + report.location, + report.request_id, + )) + .await + { + warn!( + "v201 TriggerMessage(PublishFirmwareStatusNotification): re-report of \ + {:?} (request {:?}) failed: {e}", + report.status, report.request_id + ); + } + } + /// Emit a 2.0.1 `StatusNotification` for the EVSE(s) a `TriggerMessage` /// targets (`requestedMessage = StatusNotification`). /// @@ -16436,6 +16495,160 @@ mod tests { ); } + // --- OCPP 2.0.1 TriggerMessage(PublishFirmwareStatusNotification) (M7, Issue #585) --- + // A CSMS asking `TriggerMessage(requestedMessage = PublishFirmwareStatusNotification)` + // gets the station's latest publish-firmware status re-reported as one + // PublishFirmwareStatusNotification — the publish-to-local-cache twin of #583. + // The status is recorded at the `send_v201_publish_firmware_status` choke point + // across the whole publish lifecycle, and retained past the in-flight marker's + // clear (with the cached-image `location` on the terminal `Published`). + + fn publish_firmware_status_notification_routes( + ) -> std::collections::HashMap { + let mut routes = std::collections::HashMap::new(); + routes.insert( + "BootNotification".to_string(), + boot_response("Accepted", 3600), + ); + // PublishFirmwareStatusNotification.conf is an empty ack. + routes.insert( + "PublishFirmwareStatusNotification".to_string(), + serde_json::json!({}), + ); + routes + } + + /// Drain the capturing channel for the next `PublishFirmwareStatusNotification` + /// CALL, deserialized into the typed 2.0.1 request. Skips the boot/status + /// chatter a freshly-connected CP emits; bounded by a timeout so a missing CALL + /// fails fast. + async fn recv_one_publish_firmware_status( + rx: &mut tokio::sync::mpsc::UnboundedReceiver<(String, serde_json::Value)>, + ) -> ocpp_messages::v201::PublishFirmwareStatusNotificationRequest { + loop { + match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await { + Ok(Some((action, payload))) if action == "PublishFirmwareStatusNotification" => { + return serde_json::from_value(payload) + .expect("captured PublishFirmwareStatusNotification is typed"); + } + Ok(Some(_)) => continue, // BootNotification / StatusNotification, etc. + Ok(None) | Err(_) => panic!("expected a PublishFirmwareStatusNotification CALL"), + } + } + } + + #[tokio::test] + async fn v201_trigger_publish_firmware_status_reports_idle_when_no_publish_ran() { + // A station that has never run a PublishFirmware answers the trigger with + // PublishFirmwareStatusNotification(Idle), requestId and location omitted. + let (addr, mut rx) = + spawn_mock_csms_capturing(publish_firmware_status_notification_routes()).await; + let cp = ChargePoint::new(ChargePointConfig { + central_system_url: format!("ws://{addr}"), + ..ChargePointConfig::for_version(OcppVersion::V201) + }) + .unwrap(); + cp.connect().await.unwrap(); + + cp.send_v201_triggered_message( + MessageTriggerEnumType::PublishFirmwareStatusNotification, + None, + ) + .await; + + let report = recv_one_publish_firmware_status(&mut rx).await; + assert_eq!(report.status, PublishFirmwareStatusEnumType::Idle); + assert_eq!( + report.request_id, None, + "an Idle re-report carries no requestId" + ); + assert!( + report.location.is_none(), + "an Idle re-report carries no location" + ); + } + + #[tokio::test] + async fn v201_trigger_publish_firmware_status_reports_the_latest_recorded_status() { + // With a terminal Published status recorded (as the publish emit path does), + // the trigger re-reports exactly that status + its correlating requestId AND + // the cached-image location list — a pure snapshot that starts no publish and + // leaves the in-flight set untouched. + let (addr, mut rx) = + spawn_mock_csms_capturing(publish_firmware_status_notification_routes()).await; + let cp = ChargePoint::new(ChargePointConfig { + central_system_url: format!("ws://{addr}"), + ..ChargePointConfig::for_version(OcppVersion::V201) + }) + .unwrap(); + cp.connect().await.unwrap(); + + let locations: Vec = v201_command::V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS + .iter() + .map(|s| (*s).to_string()) + .collect(); + cp.v201_publish_firmwares + .record_reported( + PublishFirmwareStatusEnumType::Published, + Some(locations.clone()), + 55, + ) + .await; + + cp.send_v201_triggered_message( + MessageTriggerEnumType::PublishFirmwareStatusNotification, + None, + ) + .await; + + let report = recv_one_publish_firmware_status(&mut rx).await; + assert_eq!(report.status, PublishFirmwareStatusEnumType::Published); + assert_eq!(report.request_id, Some(55)); + assert_eq!( + report.location, + Some(locations), + "the Published re-report reproduces the cached-image location URIs" + ); + // The re-report is a snapshot: no publish started, in-flight set untouched. + assert_eq!( + cp.v201_publish_firmwares.in_flight_count().await, + 0, + "re-report must not open a publish stream" + ); + } + + #[tokio::test] + async fn v201_publish_firmware_stream_records_its_terminal_status_for_re_report() { + // The recording is wired through the real state machine (unconnected, so the + // progress CALLs fail-and-warn — record_reported runs before the send). The + // happy path leaves the latest status at the Published terminal, carrying the + // simulated cached-image locations, correlated by requestId. + let cp = ChargePoint::new(ChargePointConfig::for_version(OcppVersion::V201)).unwrap(); + cp.v201_publish_firmwares.begin(55).await; + cp.run_v201_publish_firmware_status(55).await; + + let expected_locations: Vec = + v201_command::V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS + .iter() + .map(|s| (*s).to_string()) + .collect(); + assert_eq!( + cp.v201_publish_firmwares.last_reported().await, + crate::v201_publish_firmware::V201PublishFirmwareStatusReport { + status: PublishFirmwareStatusEnumType::Published, + location: Some(expected_locations), + request_id: Some(55), + }, + "a completed publish leaves Published (with its locations) re-reportable" + ); + // Settling the stream cleared the in-flight marker but retained the status. + assert_eq!( + cp.v201_publish_firmwares.in_flight_count().await, + 0, + "the settled stream clears the in-flight marker" + ); + } + // --- OCPP 2.0.1 GetDisplayMessages (M7, issue #508) -------------------- // A `for_version(V201)` CP answers the query synchronously (Accepted / // Unknown) off a snapshot of its `V201DisplayMessageStore`, and queues a diff --git a/crates/ocpp-cp/src/v201_command.rs b/crates/ocpp-cp/src/v201_command.rs index ed3c2f0..ed3b126 100644 --- a/crates/ocpp-cp/src/v201_command.rs +++ b/crates/ocpp-cp/src/v201_command.rs @@ -321,10 +321,13 @@ pub fn v201_reset_response( /// [`TransactionEvent`](MessageTriggerEnumType::TransactionEvent), and /// [`FirmwareStatusNotification`](MessageTriggerEnumType::FirmwareStatusNotification) /// (the CP models `UpdateFirmware` and re-reports its latest firmware status on -/// demand, Issue #583) — and to +/// demand, Issue #583), and +/// [`PublishFirmwareStatusNotification`](MessageTriggerEnumType::PublishFirmwareStatusNotification) +/// (the CP models `PublishFirmware` and re-reports its latest publish status on +/// demand, Issue #585) — and to /// [`NotImplemented`](TriggerMessageStatusEnumType::NotImplemented) for the -/// diagnostics-log-, publish-firmware-, and certificate-signing triggers the -/// simulator does not yet trigger. +/// diagnostics-log- and certificate-signing triggers the simulator does not yet +/// trigger. /// /// `MeterValues` is `Accepted` at the policy level because the CP produces meter /// readings; in 2.0.1 those ride inside `TransactionEvent`, so the slice-5b @@ -349,25 +352,27 @@ pub fn v201_trigger_message_status( }; match requested { // Messages this CP already builds and sends on the live V201 path. - // `FirmwareStatusNotification` re-reports the station's latest firmware - // status on demand — the CP models `UpdateFirmware` and retains that - // status, so the trigger is honored (Issue #583), the direct twin of the - // 1.6J `TriggerMessage(FirmwareStatusNotification)` re-report. + // `FirmwareStatusNotification` / `PublishFirmwareStatusNotification` + // re-report the station's latest firmware / publish-firmware status on + // demand — the CP models `UpdateFirmware` (#583) and `PublishFirmware` + // (#585) and retains each latest status, so the triggers are honored, the + // direct twins of the 1.6J `TriggerMessage(FirmwareStatusNotification)` + // re-report. BootNotification | Heartbeat | StatusNotification | MeterValues | TransactionEvent - | FirmwareStatusNotification => TriggerMessageStatusEnumType::Accepted, - // Diagnostics-log-, publish-firmware-, and certificate-signing flows the - // simulator does not yet trigger: recognized but not triggerable. - // (LogStatusNotification → #584, PublishFirmwareStatusNotification → #585 - // will re-report their latest status the same way #583 does.) + | FirmwareStatusNotification + | PublishFirmwareStatusNotification => TriggerMessageStatusEnumType::Accepted, + // Diagnostics-log- and certificate-signing flows the simulator does not + // yet trigger: recognized but not triggerable. (LogStatusNotification → + // #584 will re-report its latest status the same way #583/#585 do; the + // Sign* certificate triggers are a separate, heavier slice.) LogStatusNotification | SignChargingStationCertificate | SignV2GCertificate - | SignCombinedCertificate - | PublishFirmwareStatusNotification => TriggerMessageStatusEnumType::NotImplemented, + | SignCombinedCertificate => TriggerMessageStatusEnumType::NotImplemented, } } @@ -2498,13 +2503,18 @@ pub const V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS: &[&str] = &[ /// Ports the progress carrier of /// [`ocpp.v201.call.PublishFirmwareStatusNotification`](https://github.com/mobilityhouse/ocpp/blob/master/ocpp/v201/call.py): /// the single [`PublishFirmwareStatusEnumType`], the correlating `requestId` -/// (always present here — a simulator only emits these while driving a publish it -/// accepted, never off a bare `TriggerMessage`), and the optional `location` URI -/// list. Per the spec `location` is required only on the terminal +/// (always present here — the async-progress path only emits these while driving a +/// publish it accepted), and the optional `location` URI list. Per the spec +/// `location` is required only on the terminal /// [`Published`](PublishFirmwareStatusEnumType::Published) state and absent on the /// intermediate lifecycle states; the caller passes `Some(list)` only there, so /// this builder simply forwards whatever it is given. /// +/// A thin wrapper over [`v201_publish_firmware_status_report`] fixing +/// `request_id` to `Some` — the shape every async progress step carries; the +/// re-report path a `TriggerMessage` drives uses the report constructor directly +/// because its `Idle` snapshot has no `requestId`. +/// /// `request_id` is copied into the message and never parsed or indexed, so an /// extreme value (`i32::MIN`/`MAX`) cannot panic; `location` is simulator-supplied /// (see [`V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS`]), never attacker input. @@ -2513,11 +2523,34 @@ pub fn v201_publish_firmware_status_notification( status: PublishFirmwareStatusEnumType, location: Option>, request_id: i32, +) -> PublishFirmwareStatusNotificationRequest { + v201_publish_firmware_status_report(status, location, Some(request_id)) +} + +/// Build a `PublishFirmwareStatusNotification.req` +/// ([`PublishFirmwareStatusNotificationRequest`]) with an *optional* `request_id`. +/// +/// The re-report constructor a `TriggerMessage(PublishFirmwareStatusNotification)` +/// uses to answer with the station's latest publish-firmware status (Issue #585) — +/// the publish-to-local-cache twin of [`v201_firmware_status_report`]. Unlike the +/// async-progress [`v201_publish_firmware_status_notification`], `request_id` may +/// be `None`: an [`Idle`](PublishFirmwareStatusEnumType::Idle) status on a station +/// that has never run a `PublishFirmware` is not tied to a specific request, and +/// the 2.0.1 schema omits `requestId` (`skip_serializing_if = "Option::is_none"`) +/// in that case; a status carried over from a real publish re-reports its +/// `Some(requestId)`. `location` is forwarded as given — `Some` only on the +/// terminal [`Published`](PublishFirmwareStatusEnumType::Published) re-report. +/// Ports `ocpp.v201.call.PublishFirmwareStatusNotification`. +#[must_use] +pub fn v201_publish_firmware_status_report( + status: PublishFirmwareStatusEnumType, + location: Option>, + request_id: Option, ) -> PublishFirmwareStatusNotificationRequest { PublishFirmwareStatusNotificationRequest { status, location, - request_id: Some(request_id), + request_id, custom_data: None, } } @@ -3727,6 +3760,9 @@ mod tests { // The CP models UpdateFirmware and retains its latest firmware status, // so a FirmwareStatusNotification trigger is honored by re-report (#583). MessageTriggerEnumType::FirmwareStatusNotification, + // Likewise the CP models PublishFirmware and retains its latest publish + // status, so a PublishFirmwareStatusNotification trigger is honored (#585). + MessageTriggerEnumType::PublishFirmwareStatusNotification, ] { assert_eq!( v201_trigger_message_status(requested), @@ -3736,9 +3772,8 @@ mod tests { } } - /// The diagnostics-log-, publish-firmware-, and certificate-signing triggers - /// the simulator does not yet originate resolve to `NotImplemented` - /// (recognized, not triggerable). + /// The diagnostics-log- and certificate-signing triggers the simulator does + /// not yet originate resolve to `NotImplemented` (recognized, not triggerable). #[test] fn unsupported_triggers_are_not_implemented() { for requested in [ @@ -3746,7 +3781,6 @@ mod tests { MessageTriggerEnumType::SignChargingStationCertificate, MessageTriggerEnumType::SignV2GCertificate, MessageTriggerEnumType::SignCombinedCertificate, - MessageTriggerEnumType::PublishFirmwareStatusNotification, ] { assert_eq!( v201_trigger_message_status(requested), @@ -3785,10 +3819,10 @@ mod tests { } } } - assert_eq!(accepted, 6, "expected exactly 6 producible triggers"); + assert_eq!(accepted, 7, "expected exactly 7 producible triggers"); assert_eq!( - not_implemented, 5, - "expected exactly 5 unsupported triggers" + not_implemented, 4, + "expected exactly 4 unsupported triggers" ); } @@ -6911,6 +6945,91 @@ mod tests { } } + #[test] + fn publish_firmware_status_report_omits_request_id_when_none() { + // The Idle re-report a TriggerMessage(PublishFirmwareStatusNotification) + // answers with on a station that has never run a publish (#585): Idle, no + // location, no requestId. + let req = + v201_publish_firmware_status_report(PublishFirmwareStatusEnumType::Idle, None, None); + assert_eq!(req.status, PublishFirmwareStatusEnumType::Idle); + assert_eq!(req.request_id, None); + assert!(req.location.is_none()); + assert!(req.custom_data.is_none()); + // `requestId` (and `location`) must be *absent* from the wire, not `null` — + // the schema has no `null` for them (skip_serializing_if = "Option::is_none"). + let payload = serde_json::to_value(&req).unwrap(); + assert!( + payload.get("requestId").is_none(), + "an Idle re-report omits requestId entirely, got: {payload}" + ); + assert!( + payload.get("location").is_none(), + "an Idle re-report omits location entirely, got: {payload}" + ); + } + + #[test] + fn publish_firmware_status_report_carries_request_id_and_location_when_some() { + // A Published status re-reported from a real publish keeps its correlating + // requestId and the cached-image location list. + let locations: Vec = V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS + .iter() + .map(|s| (*s).to_string()) + .collect(); + let req = v201_publish_firmware_status_report( + PublishFirmwareStatusEnumType::Published, + Some(locations.clone()), + Some(7), + ); + assert_eq!(req.status, PublishFirmwareStatusEnumType::Published); + assert_eq!(req.location, Some(locations.clone())); + assert_eq!(req.request_id, Some(7)); + // The async-progress wrapper produces the same shape as the explicit Some. + assert_eq!( + req, + v201_publish_firmware_status_notification( + PublishFirmwareStatusEnumType::Published, + Some(locations), + 7, + ) + ); + } + + #[test] + fn built_publish_firmware_status_reports_are_schema_valid() { + // The trigger re-report path (optional requestId) is schema-valid both as + // an Idle/None snapshot and carrying a status + requestId (+ location on + // Published) from a real publish. + let validator = SchemaValidator::v201(); + let locations: Vec = V201_SIMULATED_PUBLISH_FIRMWARE_LOCATIONS + .iter() + .map(|s| (*s).to_string()) + .collect(); + for status in [ + PublishFirmwareStatusEnumType::Idle, + PublishFirmwareStatusEnumType::Downloading, + PublishFirmwareStatusEnumType::Published, + PublishFirmwareStatusEnumType::DownloadFailed, + PublishFirmwareStatusEnumType::PublishFailed, + ] { + // Published carries the location list; every other state omits it. + let location = + (status == PublishFirmwareStatusEnumType::Published).then(|| locations.clone()); + for request_id in [None, Some(0), Some(-1), Some(i32::MIN), Some(i32::MAX)] { + let req = v201_publish_firmware_status_report(status, location.clone(), request_id); + let payload = serde_json::to_value(&req).unwrap(); + assert!( + validator + .validate_call("PublishFirmwareStatusNotification", &payload) + .is_ok(), + "built {status:?} PublishFirmwareStatusNotification.req (requestId \ + {request_id:?}) should be schema-valid, got: {payload}" + ); + } + } + } + // --- ClearDisplayMessage (v201, #509) ---------------------------------- #[test] diff --git a/crates/ocpp-cp/src/v201_publish_firmware.rs b/crates/ocpp-cp/src/v201_publish_firmware.rs index 4ab79e9..4664cb6 100644 --- a/crates/ocpp-cp/src/v201_publish_firmware.rs +++ b/crates/ocpp-cp/src/v201_publish_firmware.rs @@ -37,19 +37,81 @@ //! //! [`V201CustomerInformationStore`]: crate::v201_customer_information::V201CustomerInformationStore +use ocpp_types::v201::PublishFirmwareStatusEnumType; use std::collections::HashSet; use tokio::sync::RwLock; +/// The most recent publish-firmware status the station reported, retained so a +/// `TriggerMessage(PublishFirmwareStatusNotification)` can re-report it on demand +/// (Issue #585) — the publish-to-local-cache twin of +/// [`V201FirmwareStatusReport`](crate::v201_firmware_update::V201FirmwareStatusReport). +/// +/// A station reports publish progress asynchronously +/// (`PublishFirmwareStatusNotification(Idle → … → Published)`), but a CSMS may +/// ask for the *current* status at any point via `TriggerMessage`. This snapshot +/// is the answer: [`Idle`](PublishFirmwareStatusEnumType::Idle) with no +/// `request_id` and no `location` until the first `PublishFirmware` progress step +/// is emitted, then the latest `(status, location, requestId)` thereafter — +/// retained even after the in-flight marker is cleared, so a settled `Published` +/// (or a terminal failure) is still reportable. +/// +/// Unlike the single-slot firmware-update snapshot this carries `location`: the +/// terminal [`Published`](PublishFirmwareStatusEnumType::Published) advertises the +/// cached image's download URIs, so a faithful re-report must reproduce them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V201PublishFirmwareStatusReport { + /// The latest reported stage of the firmware-publish lifecycle. + pub status: PublishFirmwareStatusEnumType, + /// The URIs the published image can be downloaded from — `Some` only when the + /// latest reported `status` was [`Published`](PublishFirmwareStatusEnumType::Published), + /// `None` for every intermediate state and the initial `Idle` (mirroring what + /// the async progress stream itself carries per state). + pub location: Option>, + /// The `requestId` of the `PublishFirmware` that stage belongs to, or `None` + /// for the initial [`Idle`](PublishFirmwareStatusEnumType::Idle) (no publish + /// has run, so the status is not tied to a specific request — the 2.0.1 schema + /// omits `requestId` in that case). + pub request_id: Option, +} + +impl Default for V201PublishFirmwareStatusReport { + /// A station that has never run a publish: + /// [`Idle`](PublishFirmwareStatusEnumType::Idle), no `location`, no + /// correlating `requestId`. + fn default() -> Self { + Self { + status: PublishFirmwareStatusEnumType::Idle, + location: None, + request_id: None, + } + } +} + /// Tracks the set of `PublishFirmware` progress streams currently in flight, by -/// their `requestId`. +/// their `requestId`, plus the latest publish-firmware status the station has +/// reported. /// -/// Each `requestId` is CSMS-supplied and stored as an opaque `i32` — only ever -/// inserted, compared, and removed, never parsed or indexed — so no wire value -/// (including `i32::MIN`/`MAX`) can panic here. +/// For the in-flight set: each `requestId` is CSMS-supplied and stored as an +/// opaque `i32` — only ever inserted, compared, and removed, never parsed or +/// indexed — so no wire value (including `i32::MIN`/`MAX`) can panic here. +/// +/// Separately, [`last_reported`](Self::last_reported) retains the most recent +/// [`V201PublishFirmwareStatusReport`] the station emitted (via +/// [`record_reported`](Self::record_reported)), so a +/// `TriggerMessage(PublishFirmwareStatusNotification)` can re-report the current +/// status without re-running a publish. It is deliberately *not* cleared when an +/// in-flight id settles ([`complete`](Self::complete)): a finished publish leaves +/// the station's last status (`Published`, or a terminal failure) as the truthful +/// thing to report. With independent per-id streams the "latest" is simply the +/// most recent emit across all of them — the station's current publish status. #[derive(Debug, Default)] pub struct V201PublishFirmwareStore { /// The `requestId`s whose progress streams are currently in flight. in_flight: RwLock>, + /// The most recent publish-firmware status the station reported. `Idle` (no + /// `location`/`requestId`) until the first progress step; see + /// [`V201PublishFirmwareStatusReport`]. + last_reported: RwLock, } impl V201PublishFirmwareStore { @@ -99,6 +161,44 @@ impl V201PublishFirmwareStore { pub async fn complete(&self, request_id: i32) -> bool { self.in_flight.write().await.remove(&request_id) } + + /// Record `status` (from `PublishFirmware` request `request_id`, carrying + /// `location` only on the terminal `Published` state) as the latest + /// publish-firmware status the station has reported. + /// + /// Called at the single emit choke point + /// (`ChargePoint::send_v201_publish_firmware_status`) for every progress step, + /// so the snapshot tracks the full lifecycle — interim (`DownloadScheduled` … + /// `Downloaded`) and terminal (`Published` / a failure state). It is + /// independent of the in-flight set: a completed publish clears its id but this + /// retains the terminal status so a later + /// `TriggerMessage(PublishFirmwareStatusNotification)` still re-reports it. With + /// independent per-id streams the newest emit wins, which is the station's + /// current publish status. `request_id` is only stored, never parsed or + /// indexed, so no wire value can panic. + pub async fn record_reported( + &self, + status: PublishFirmwareStatusEnumType, + location: Option>, + request_id: i32, + ) { + *self.last_reported.write().await = V201PublishFirmwareStatusReport { + status, + location, + request_id: Some(request_id), + }; + } + + /// The most recent [`V201PublishFirmwareStatusReport`] the station has + /// reported. + /// + /// The snapshot a `TriggerMessage(PublishFirmwareStatusNotification)` + /// re-reports on demand. Defaults to [`Idle`](PublishFirmwareStatusEnumType::Idle) + /// with no `location`/`requestId` on a station that has never run a publish. + /// Returns a clone, so the caller decides without holding the store lock. + pub async fn last_reported(&self) -> V201PublishFirmwareStatusReport { + self.last_reported.read().await.clone() + } } #[cfg(test)] @@ -180,4 +280,97 @@ mod tests { assert!(store.complete(i32::MAX).await); assert_eq!(store.in_flight_count().await, 0); } + + #[tokio::test] + async fn a_new_store_reports_idle_with_no_location_or_request_id() { + let store = V201PublishFirmwareStore::new(); + assert_eq!( + store.last_reported().await, + V201PublishFirmwareStatusReport { + status: PublishFirmwareStatusEnumType::Idle, + location: None, + request_id: None, + }, + "a station that has never run a publish reports Idle, no location/requestId" + ); + } + + #[tokio::test] + async fn record_reported_tracks_the_latest_status_and_request_id() { + let store = V201PublishFirmwareStore::new(); + store + .record_reported(PublishFirmwareStatusEnumType::Downloading, None, 42) + .await; + assert_eq!( + store.last_reported().await, + V201PublishFirmwareStatusReport { + status: PublishFirmwareStatusEnumType::Downloading, + location: None, + request_id: Some(42), + } + ); + // The latest wins — the terminal Published step overwrites the earlier one + // and carries the cached-image download URIs. + let uris = vec!["http://lc.lan/fw.bin".to_string()]; + store + .record_reported( + PublishFirmwareStatusEnumType::Published, + Some(uris.clone()), + 42, + ) + .await; + assert_eq!( + store.last_reported().await, + V201PublishFirmwareStatusReport { + status: PublishFirmwareStatusEnumType::Published, + location: Some(uris), + request_id: Some(42), + }, + "the terminal Published re-report retains its location URIs" + ); + } + + #[tokio::test] + async fn completing_the_stream_retains_the_last_reported_status() { + // A settled publish clears the id from the in-flight set, but the terminal + // status (with its location) must remain reportable for a later trigger. + let store = V201PublishFirmwareStore::new(); + store.begin(9).await; + let uris = vec!["ftp://lc.lan/fw.bin".to_string()]; + store + .record_reported( + PublishFirmwareStatusEnumType::Published, + Some(uris.clone()), + 9, + ) + .await; + assert!(store.complete(9).await); + assert_eq!(store.in_flight_count().await, 0, "in-flight marker cleared"); + assert_eq!( + store.last_reported().await, + V201PublishFirmwareStatusReport { + status: PublishFirmwareStatusEnumType::Published, + location: Some(uris), + request_id: Some(9), + }, + "the terminal status survives the in-flight clear" + ); + } + + #[tokio::test] + async fn record_reported_accepts_extreme_request_ids() { + let store = V201PublishFirmwareStore::new(); + store + .record_reported( + PublishFirmwareStatusEnumType::DownloadFailed, + None, + i32::MIN, + ) + .await; + assert_eq!(store.last_reported().await.request_id, Some(i32::MIN)); + store + .record_reported(PublishFirmwareStatusEnumType::PublishFailed, None, i32::MAX) + .await; + assert_eq!(store.last_reported().await.request_id, Some(i32::MAX)); + } }