Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/stekker_zaptec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions lib/zaptec/archived_session.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions lib/zaptec/archived_session_energy_point.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/zaptec/archived_session_user.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/zaptec/archived_sessions_page.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions lib/zaptec/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 15 additions & 8 deletions lib/zaptec/meter_reading.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions spec/zaptec/archived_session_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading