diff --git a/lib/stekker_zaptec.rb b/lib/stekker_zaptec.rb index 5b315fe..b00e119 100644 --- a/lib/stekker_zaptec.rb +++ b/lib/stekker_zaptec.rb @@ -2,6 +2,10 @@ require "active_model" require "active_support/all" +require "zaptec/archived_session" +require "zaptec/archived_session_energy_point" +require "zaptec/archived_session_user" +require "zaptec/archived_sessions_page" require "zaptec/charger" require "zaptec/circuit" require "zaptec/client" diff --git a/lib/zaptec/archived_session.rb b/lib/zaptec/archived_session.rb new file mode 100644 index 0000000..1857366 --- /dev/null +++ b/lib/zaptec/archived_session.rb @@ -0,0 +1,55 @@ +module Zaptec + class ArchivedSession + def initialize(data) + @data = data.deep_symbolize_keys + end + + def id = @data.fetch(:id) + def external_id = @data[:externalId] + def replaced_by_session_id = @data[:replacedBySessionId] + def token_name = @data[:tokenName] + def charger_id = @data[:chargerId] + def device_id = @data[:deviceId] + def device_name = @data[:deviceName] + def charger_firmware_version = @data[:chargerFirmwareVersion] + def energy_kwh = @data[:energy] + def session_signature = @data[:sessionSignature] + def session_signature_eichrecht = @data[:sessionSignatureEichreicht] + + def start_date_time = parse_time(:startDateTime) + def end_date_time = parse_time(:endDateTime) + def recognized_date_time = parse_time(:recognizedDateTime) + + def offline? = @data.fetch(:offline, false) + def reliable_clock? = @data.fetch(:reliableClock, false) + def stopped_by_rfid? = @data.fetch(:stoppedByRfid, false) + def signed? = @data.fetch(:signed, false) + def voided? = @data.fetch(:voided, false) + def aborted? = @data.fetch(:aborted, false) + def ocpp_native? = @data.fetch(:ocppNative, false) + def externally_abandoned? = @data.fetch(:externallyAbandoned, false) + + def authorized_user + @authorized_user ||= @data[:authorizedUser].then do |user| + user.nil? ? nil : ArchivedSessionUser.new(user) + end + end + + def energy_details + @energy_details ||= (@data[:energyDetails] || []).map { |point| ArchivedSessionEnergyPoint.new(point) } + end + + def meter_readings + @meter_readings ||= MeterReading.parse_all(session_signature) + end + + private + + def parse_time(key) + value = @data[key] + return if value.nil? + + Time.zone.parse(value) + end + end +end diff --git a/lib/zaptec/archived_session_energy_point.rb b/lib/zaptec/archived_session_energy_point.rb new file mode 100644 index 0000000..8a1ffe1 --- /dev/null +++ b/lib/zaptec/archived_session_energy_point.rb @@ -0,0 +1,10 @@ +module Zaptec + class ArchivedSessionEnergyPoint + def initialize(data) + @data = data.deep_symbolize_keys + end + + def timestamp = Time.zone.parse(@data.fetch(:timestamp)) + def energy_kwh = @data[:energy] + end +end diff --git a/lib/zaptec/archived_session_user.rb b/lib/zaptec/archived_session_user.rb new file mode 100644 index 0000000..74bfa25 --- /dev/null +++ b/lib/zaptec/archived_session_user.rb @@ -0,0 +1,11 @@ +module Zaptec + class ArchivedSessionUser + def initialize(data) + @data = data.deep_symbolize_keys + end + + def id = @data.fetch(:id) + def email = @data[:email] + def full_name = @data[:fullName] + end +end diff --git a/lib/zaptec/archived_sessions_page.rb b/lib/zaptec/archived_sessions_page.rb new file mode 100644 index 0000000..1919d7d --- /dev/null +++ b/lib/zaptec/archived_sessions_page.rb @@ -0,0 +1,13 @@ +module Zaptec + class ArchivedSessionsPage + attr_reader :sessions, :cursor + + def initialize(sessions:, cursor:, has_more:) + @sessions = sessions + @cursor = cursor + @has_more = has_more + end + + def has_more? = @has_more + end +end diff --git a/lib/zaptec/client.rb b/lib/zaptec/client.rb index 44f11ca..2c1211f 100644 --- a/lib/zaptec/client.rb +++ b/lib/zaptec/client.rb @@ -140,6 +140,27 @@ def update_charger(charger_id, **attributes) post("/api/chargers/#{charger_id}/update", body: attributes) end + # https://api.zaptec.com/help/index.html#/Session/api_sessions_archived_get + def archived_sessions(from:, to:, installation_id: nil, charger_id: nil, page_size: nil, cursor: nil) + if installation_id.blank? == charger_id.blank? + raise Errors::ParameterMissing, "Provide exactly one of installation_id or charger_id" + end + + params = { From: from.iso8601, To: to.iso8601 } + params[:InstallationId] = installation_id if installation_id.present? + params[:ChargerId] = charger_id if charger_id.present? + params[:PageSize] = page_size if page_size.present? + params[:Cursor] = cursor if cursor.present? + + body = get("/api/sessions/archived", params).body + + ArchivedSessionsPage.new( + sessions: (body["sessions"] || []).map { |data| ArchivedSession.new(data) }, + cursor: body["cursor"], + has_more: body.fetch("hasMore", false), + ) + end + def pause_charging(charger_id) = send_command(charger_id, :StopChargingFinal) def resume_charging(charger_id) = send_command(charger_id, :ResumeCharging) diff --git a/lib/zaptec/meter_reading.rb b/lib/zaptec/meter_reading.rb index 8d01961..efc2638 100644 --- a/lib/zaptec/meter_reading.rb +++ b/lib/zaptec/meter_reading.rb @@ -19,25 +19,32 @@ def initialize(reading_kwh:, timestamp:) class << self def parse(ocmf_meter_reading) - _prefix, json_payload, _signature = ocmf_meter_reading.split("|") + parse_all(ocmf_meter_reading).first + end + + def parse_all(ocmf_meter_reading) + return [] if ocmf_meter_reading.blank? + _prefix, json_payload, _signature = ocmf_meter_reading.split("|") data = JSON.parse(json_payload) - meter_reading = data.fetch(READINGS).detect do |reading| - reading.fetch(STATUS) == GOOD && reading[UNIT].in?([WH, KWH]) - end + data.fetch(READINGS, []) + .select { |reading| reading[STATUS] == GOOD && reading[UNIT].in?([WH, KWH]) } + .map { |reading| build_reading(reading) } + end - return if meter_reading.blank? + private - timestamp = Time.zone.parse(meter_reading.fetch(TIMESTAMP).split.first) + def build_reading(reading) + timestamp = Time.zone.parse(reading.fetch(TIMESTAMP).split.first) kwh_magnitude = - case meter_reading.fetch(UNIT) + case reading.fetch(UNIT) when KWH then 1 when WH then 1000.0 end - reading_kwh = meter_reading.fetch(VALUE) / kwh_magnitude + reading_kwh = reading.fetch(VALUE) / kwh_magnitude new(reading_kwh:, timestamp:) end diff --git a/spec/zaptec/archived_session_spec.rb b/spec/zaptec/archived_session_spec.rb new file mode 100644 index 0000000..1564a33 --- /dev/null +++ b/spec/zaptec/archived_session_spec.rb @@ -0,0 +1,205 @@ +RSpec.describe Zaptec::ArchivedSession do + it "exposes the top-level session fields" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "externalId" => "tx-42", + "chargerId" => "c0000000-0000-0000-0000-000000000001", + "deviceId" => "ZAP123456", + "deviceName" => "Zaptec-1", + "chargerFirmwareVersion" => "3.2.1", + "startDateTime" => "2026-01-05T10:00:00Z", + "endDateTime" => "2026-01-05T11:30:00Z", + "recognizedDateTime" => "2026-01-05T11:30:05Z", + "energy" => 12.34, + "tokenName" => "RFID-ABC", + ) + + expect(session).to have_attributes( + id: "b1e5a5f0-1111-4c8b-9d2f-000000000001", + external_id: "tx-42", + charger_id: "c0000000-0000-0000-0000-000000000001", + device_id: "ZAP123456", + device_name: "Zaptec-1", + charger_firmware_version: "3.2.1", + start_date_time: Time.zone.parse("2026-01-05T10:00:00Z"), + end_date_time: Time.zone.parse("2026-01-05T11:30:00Z"), + recognized_date_time: Time.zone.parse("2026-01-05T11:30:05Z"), + energy_kwh: 12.34, + token_name: "RFID-ABC", + ) + end + + it "returns nil for optional timestamps and identifiers when absent" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + ) + + expect(session).to have_attributes( + external_id: nil, + end_date_time: nil, + recognized_date_time: nil, + device_id: nil, + device_name: nil, + charger_firmware_version: nil, + token_name: nil, + replaced_by_session_id: nil, + energy_kwh: nil, + ) + end + + it "exposes the boolean flags" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "offline" => true, + "reliableClock" => false, + "stoppedByRfid" => true, + "signed" => true, + "voided" => false, + "aborted" => false, + "ocppNative" => true, + "externallyAbandoned" => false, + ) + + expect(session).to be_offline + expect(session).not_to be_reliable_clock + expect(session).to be_stopped_by_rfid + expect(session).to be_signed + expect(session).not_to be_voided + expect(session).not_to be_aborted + expect(session).to be_ocpp_native + expect(session).not_to be_externally_abandoned + end + + it "defaults the boolean flags to false when the API omits them" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + ) + + expect(session).not_to be_offline + expect(session).not_to be_reliable_clock + expect(session).not_to be_stopped_by_rfid + expect(session).not_to be_signed + expect(session).not_to be_voided + expect(session).not_to be_aborted + expect(session).not_to be_ocpp_native + expect(session).not_to be_externally_abandoned + end + + it "exposes the replaced-by session id when the session was voided" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "voided" => true, + "replacedBySessionId" => "b1e5a5f0-1111-4c8b-9d2f-000000000099", + ) + + expect(session).to be_voided + expect(session.replaced_by_session_id).to eq "b1e5a5f0-1111-4c8b-9d2f-000000000099" + end + + it "exposes the OCMF payloads when present" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "sessionSignature" => "OCMF|{...}|sig", + "sessionSignatureEichreicht" => "OCMF|{...}|sig-eichrecht", + ) + + expect(session.session_signature).to eq "OCMF|{...}|sig" + expect(session.session_signature_eichrecht).to eq "OCMF|{...}|sig-eichrecht" + end + + describe "#authorized_user" do + it "parses the authorized user when present" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "authorizedUser" => { + "id" => "u0000000-0000-0000-0000-000000000001", + "email" => "driver@example.com", + "fullName" => "Anne Driver", + }, + ) + + expect(session.authorized_user) + .to have_attributes( + id: "u0000000-0000-0000-0000-000000000001", + email: "driver@example.com", + full_name: "Anne Driver", + ) + end + + it "is nil when the authorized user is absent" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + ) + + expect(session.authorized_user).to be_nil + end + end + + describe "#meter_readings" do + it "parses the OCMF sessionSignature into cumulative meter readings" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "sessionSignature" => + 'OCMF|{"FV":"1.0","RD":[' \ + '{"TM":"2026-01-05T10:00:00,000+00:00 R","TX":"B","RV":100.0,"RU":"kWh","ST":"G"},' \ + '{"TM":"2026-01-05T11:00:00,000+00:00 R","TX":"T","RV":103.5,"RU":"kWh","ST":"G"},' \ + '{"TM":"2026-01-05T12:00:00,000+00:00 R","TX":"E","RV":110.2,"RU":"kWh","ST":"G"}' \ + ']}|{"SD":"abc"}', + ) + + expect(session.meter_readings).to match( + [ + have_attributes(timestamp: Time.zone.parse("2026-01-05T10:00:00Z"), reading_kwh: 100.0), + have_attributes(timestamp: Time.zone.parse("2026-01-05T11:00:00Z"), reading_kwh: 103.5), + have_attributes(timestamp: Time.zone.parse("2026-01-05T12:00:00Z"), reading_kwh: 110.2), + ], + ) + end + + it "returns an empty array when no sessionSignature is available" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + ) + + expect(session.meter_readings).to eq [] + end + end + + describe "#energy_details" do + it "parses the per-timestamp energy readings when present" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + "energyDetails" => [ + { "timestamp" => "2026-01-05T10:15:00Z", "energy" => 1.5 }, + { "timestamp" => "2026-01-05T10:30:00Z", "energy" => 3.2 }, + ], + ) + + expect(session.energy_details).to match( + [ + have_attributes(timestamp: Time.zone.parse("2026-01-05T10:15:00Z"), energy_kwh: 1.5), + have_attributes(timestamp: Time.zone.parse("2026-01-05T10:30:00Z"), energy_kwh: 3.2), + ], + ) + end + + it "returns an empty array when the API omits the readings" do + session = described_class.new( + "id" => "b1e5a5f0-1111-4c8b-9d2f-000000000001", + "startDateTime" => "2026-01-05T10:00:00Z", + ) + + expect(session.energy_details).to eq [] + end + end +end diff --git a/spec/zaptec/client_spec.rb b/spec/zaptec/client_spec.rb index c9d3bb2..9f3bdce 100644 --- a/spec/zaptec/client_spec.rb +++ b/spec/zaptec/client_spec.rb @@ -753,6 +753,193 @@ end end + describe "#archived_sessions" do + it "fetches a page of archived sessions for an installation" do + from = Time.zone.parse("2026-01-01T00:00:00Z") + to = Time.zone.parse("2026-02-01T00:00:00Z") + + WebMock::API + .stub_request(:get, "https://api.zaptec.com/api/sessions/archived") + .with( + query: { + From: "2026-01-01T00:00:00Z", + To: "2026-02-01T00:00:00Z", + InstallationId: "I123", + }, + ) + .to_return( + body: { + sessions: [ + archived_session_example(id: "s0000000-0000-0000-0000-000000000001", energy: 1.23), + archived_session_example(id: "s0000000-0000-0000-0000-000000000002", energy: 4.56), + ], + cursor: nil, + hasMore: false, + }.to_json, + headers: { "Content-Type": "application/json" }, + ) + + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + page = client.archived_sessions(from:, to:, installation_id: "I123") + + expect(page).to be_a(Zaptec::ArchivedSessionsPage) + expect(page.sessions.map(&:id)).to eq %w[ + s0000000-0000-0000-0000-000000000001 + s0000000-0000-0000-0000-000000000002 + ] + expect(page.sessions.map(&:energy_kwh)).to eq [1.23, 4.56] + expect(page.cursor).to be_nil + expect(page).not_to have_more + end + + it "fetches archived sessions for a single charger" do + from = Time.zone.parse("2026-01-01T00:00:00Z") + to = Time.zone.parse("2026-02-01T00:00:00Z") + + WebMock::API + .stub_request(:get, "https://api.zaptec.com/api/sessions/archived") + .with( + query: { + From: "2026-01-01T00:00:00Z", + To: "2026-02-01T00:00:00Z", + ChargerId: "C123", + }, + ) + .to_return( + body: { sessions: [], cursor: nil, hasMore: false }.to_json, + headers: { "Content-Type": "application/json" }, + ) + + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + page = client.archived_sessions(from:, to:, charger_id: "C123") + + expect(page.sessions).to eq [] + end + + it "carries the cursor and hasMore flag when more pages are available" do + from = Time.zone.parse("2026-01-01T00:00:00Z") + to = Time.zone.parse("2026-02-01T00:00:00Z") + + WebMock::API + .stub_request(:get, "https://api.zaptec.com/api/sessions/archived") + .with(query: hash_including(Cursor: "opaque-cursor-123")) + .to_return( + body: { + sessions: [archived_session_example(id: "s0000000-0000-0000-0000-000000000010")], + cursor: "next-cursor-456", + hasMore: true, + }.to_json, + headers: { "Content-Type": "application/json" }, + ) + + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + page = client.archived_sessions( + from:, + to:, + installation_id: "I123", + cursor: "opaque-cursor-123", + ) + + expect(page.cursor).to eq "next-cursor-456" + expect(page).to have_more + end + + it "passes a custom page_size to the API" do + from = Time.zone.parse("2026-01-01T00:00:00Z") + to = Time.zone.parse("2026-02-01T00:00:00Z") + + stub = WebMock::API + .stub_request(:get, "https://api.zaptec.com/api/sessions/archived") + .with(query: hash_including(PageSize: "200")) + .to_return( + body: { sessions: [], cursor: nil, hasMore: false }.to_json, + headers: { "Content-Type": "application/json" }, + ) + + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + client.archived_sessions(from:, to:, installation_id: "I123", page_size: 200) + + expect(stub).to have_been_requested + end + + it "raises ParameterMissing when neither installation_id nor charger_id is given" do + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + expect do + client.archived_sessions( + from: Time.zone.parse("2026-01-01T00:00:00Z"), + to: Time.zone.parse("2026-02-01T00:00:00Z"), + ) + end.to raise_error(Zaptec::Errors::ParameterMissing) + end + + it "raises ParameterMissing when both installation_id and charger_id are given" do + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + expect do + client.archived_sessions( + from: Time.zone.parse("2026-01-01T00:00:00Z"), + to: Time.zone.parse("2026-02-01T00:00:00Z"), + installation_id: "I123", + charger_id: "C123", + ) + end.to raise_error(Zaptec::Errors::ParameterMissing) + end + + it "parses per-session energy details and the authorized user" do + WebMock::API + .stub_request(:get, "https://api.zaptec.com/api/sessions/archived") + .with(query: hash_including({})) + .to_return( + body: { + sessions: [ + archived_session_example( + id: "s0000000-0000-0000-0000-000000000001", + energy: 7.5, + extra: { + authorizedUser: { + id: "u0000000-0000-0000-0000-000000000001", + email: "driver@example.com", + fullName: "Anne Driver", + }, + energyDetails: [ + { timestamp: "2026-01-05T10:15:00Z", energy: 2.5 }, + { timestamp: "2026-01-05T10:30:00Z", energy: 5.0 }, + ], + }, + ), + ], + cursor: nil, + hasMore: false, + }.to_json, + headers: { "Content-Type": "application/json" }, + ) + + token_cache = build_token_cache("T123") + client = Zaptec::Client.new(username: "zap", password: "tec", token_cache:) + + page = client.archived_sessions( + from: Time.zone.parse("2026-01-01T00:00:00Z"), + to: Time.zone.parse("2026-02-01T00:00:00Z"), + installation_id: "I123", + ) + + session = page.sessions.first + expect(session.authorized_user.email).to eq "driver@example.com" + expect(session.energy_details.map(&:energy_kwh)).to eq [2.5, 5.0] + end + end + private def chargers_example @@ -1337,6 +1524,27 @@ def build_zaptec_charger ) end + def archived_session_example(id:, energy: 0.0, extra: {}) + { + id:, + chargerId: "c0000000-0000-0000-0000-000000000001", + deviceId: "ZAP123456", + deviceName: "Zaptec-1", + startDateTime: "2026-01-05T10:00:00Z", + endDateTime: "2026-01-05T11:00:00Z", + recognizedDateTime: "2026-01-05T11:00:05Z", + offline: false, + reliableClock: true, + stoppedByRfid: false, + signed: false, + voided: false, + aborted: false, + ocppNative: false, + externallyAbandoned: false, + energy:, + }.merge(extra) + end + def build_token_cache(access_token, expires_at: 1.hour.from_now) ActiveSupport::Cache::MemoryStore.new.tap do |token_cache| token_cache.write( diff --git a/spec/zaptec/meter_reading_spec.rb b/spec/zaptec/meter_reading_spec.rb index 3fad5e7..a043ae9 100644 --- a/spec/zaptec/meter_reading_spec.rb +++ b/spec/zaptec/meter_reading_spec.rb @@ -17,6 +17,62 @@ end end + describe "::parse_all" do + it "returns one reading per RD entry" do + readings = Zaptec::MeterReading.parse_all(<<~OCMF) + OCMF|{ + "FV": "1.0", + "RD": [ + { "TM": "2026-01-05T10:00:00,000+00:00 R", "TX": "B", "RV": 100.0, "RU": "kWh", "ST": "G" }, + { "TM": "2026-01-05T11:00:00,000+00:00 R", "TX": "T", "RV": 103.5, "RU": "kWh", "ST": "G" }, + { "TM": "2026-01-05T12:00:00,000+00:00 R", "TX": "E", "RV": 110.2, "RU": "kWh", "ST": "G" } + ] + }|{"SD":"abc"} + OCMF + + expect(readings).to match( + [ + have_attributes(timestamp: Time.zone.parse("2026-01-05T10:00:00Z"), reading_kwh: 100.0), + have_attributes(timestamp: Time.zone.parse("2026-01-05T11:00:00Z"), reading_kwh: 103.5), + have_attributes(timestamp: Time.zone.parse("2026-01-05T12:00:00Z"), reading_kwh: 110.2), + ], + ) + end + + it "converts Wh readings to kWh" do + readings = Zaptec::MeterReading.parse_all(<<~OCMF) + OCMF|{ + "FV": "1.0", + "RD": [ + { "TM": "2026-01-05T10:00:00,000+00:00 R", "TX": "B", "RV": 12000.0, "RU": "Wh", "ST": "G" } + ] + }|{"SD":"abc"} + OCMF + + expect(readings).to match([have_attributes(reading_kwh: 12.0)]) + end + + it "skips readings with a non-good status or unsupported unit" do + readings = Zaptec::MeterReading.parse_all(<<~OCMF) + OCMF|{ + "FV": "1.0", + "RD": [ + { "TM": "2026-01-05T10:00:00,000+00:00 R", "RV": 100.0, "RU": "kWh", "ST": "E" }, + { "TM": "2026-01-05T11:00:00,000+00:00 R", "RV": 103.5, "RU": "A", "ST": "G" }, + { "TM": "2026-01-05T12:00:00,000+00:00 R", "RV": 110.2, "RU": "kWh", "ST": "G" } + ] + }|{"SD":"abc"} + OCMF + + expect(readings.map(&:reading_kwh)).to eq [110.2] + end + + it "returns an empty array for a blank payload" do + expect(Zaptec::MeterReading.parse_all(nil)).to eq [] + expect(Zaptec::MeterReading.parse_all("")).to eq [] + end + end + def example_meter_reading(unit: "kWh", value: 2935.6) <<~OCMF OCMF|{