diff --git a/README.md b/README.md index 866aa18..5589092 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ChargeFlow -A CLI tool for analyzing your raw OCPP JSON messages. Useful for debugging and compatibility checks -with various Charge Point Management Systems or Charge Point implementations. +A CLI tool for analyzing your raw OCPP JSON messages. Useful for debugging and compatibility checks with various Charge +Point Management Systems or Charge Point implementations. ## Features @@ -9,26 +9,27 @@ with various Charge Point Management Systems or Charge Point implementations. - [x] Generate human-readable reports - [x] Support for remote schema registries using Kafka-compatible Schemas Registry APIs - [x] Bring your own OCPP schemas for vendor-specific extensions +- [x] Validating OCMF-compatible meter values ## Compatibility matrix | OCPP specification | Supported | |----------------------------:|:---------:| -| OCPP 1.6 | ✅ | -| OCPP 1.6 Security Extension | ✅ | -| OCPP 2.0.1 | ✅ | -| OCPP 2.1 | ✅ | +| OCPP 1.6 | ✅ | +| OCPP 1.6 Security Extension | ✅ | +| OCPP 2.0.1 | ✅ | +| OCPP 2.1 | ✅ | ### Roadmap -- [ ] Support for signed messages - [ ] Compatibility checks ## Installation ### Binary -Download the latest release for your platform from the [releases page](https://github.com/chargepi/chargeflow/releases/latest). +Download the latest release for your platform from +the [releases page](https://github.com/chargepi/chargeflow/releases/latest). **Linux / macOS** diff --git a/pkg/ocmf/OCMF.json b/pkg/ocmf/OCMF.json new file mode 100644 index 0000000..3020b24 --- /dev/null +++ b/pkg/ocmf/OCMF.json @@ -0,0 +1,344 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:OCMF:1.0.1-DRAFT:OCMF", + "title": "Open Charge Metering Format (OCMF)", + "comment": "Based on SAFE-eV/OCMF-Open-Charge-Metering-Format, Datenformat_OCMF_v1.0.1-DRAFT. ", + "type": "object", + "additionalProperties": false, + "properties": { + "header": { + "description": "Header identifying the transfer format. Always the literal string 'OCMF'.", + "type": "string", + "const": "OCMF" + }, + "payload": { + "$ref": "#/$defs/PayloadType" + }, + "signature": { + "$ref": "#/$defs/SignatureType" + } + }, + "required": [ + "header", + "payload", + "signature" + ], + "$defs": { + "PayloadType": { + "description": "the signed payload data (section 4.2). Top-level property names starting with U, V, W, X, Y or Z are reserved for vendor-specific extensions.", + "type": "object", + "patternProperties": { + "^[UVWXYZ]": {} + }, + "properties": { + "FV": { + "description": "Format-Version: version of this data format as ..", + "type": "string", + "examples": [ + "1.0" + ] + }, + "GI": { + "description": "Gateway-Identification: identifier of the manufacturer for the signature-generating system (manufacturer, model, variant, etc.).", + "type": "string" + }, + "GS": { + "description": "Gateway-Serial: serial number of the signature-generating system. Conditionally mandatory - required if the signing component is not otherwise uniquely identified (see section 4.4).", + "type": "string" + }, + "GV": { + "description": "Gateway-Version: version designation of the manufacturer for the software of the signature-generating system.", + "type": "string" + }, + "PG": { + "description": "Pagination: reference of the whole record within a stream of readings sharing one signature. Format , without leading zeros. Context 'T' (Transaction) is mandatory to support; 'F' (Fiscal) is optional.", + "type": "string", + "pattern": "^[FT](0|[1-9][0-9]*)$", + "examples": [ + "T12345" + ] + }, + "MV": { + "description": "Meter-Vendor: manufacturer identification of the meter.", + "type": "string" + }, + "MM": { + "description": "Meter-Model: model identification of the meter.", + "type": "string" + }, + "MS": { + "description": "Meter-Serial: serial number of the meter. Mandatory whenever the meter-identification field group (MV/MM/MS/MF) is used.", + "type": "string" + }, + "MF": { + "description": "Meter-Firmware: firmware version of the meter.", + "type": "string" + }, + "IS": { + "description": "Identification-Status: whether a user could be assigned to the reading (true) or not (false). Present if, and only if, there is a transaction reference.", + "type": "boolean" + }, + "IL": { + "description": "Identification-Level: overall status of the user assignment.", + "type": "string", + "enum": [ + "NONE", + "HEARSAY", + "TRUSTED", + "VERIFIED", + "CERTIFIED", + "SECURE", + "MISMATCH", + "INVALID", + "OUTDATED", + "UNKNOWN" + ] + }, + "IF": { + "description": "Identification-Flags: detail information about the user assignment.", + "type": "array", + "maxItems": 4, + "items": { + "type": "string", + "enum": [ + "RFID_NONE", + "RFID_PLAIN", + "RFID_RELATED", + "RFID_PSK", + "OCPP_NONE", + "OCPP_RS", + "OCPP_AUTH", + "OCPP_RS_TLS", + "OCPP_AUTH_TLS", + "OCPP_CACHE", + "OCPP_WHITELIST", + "OCPP_CERTIFIED", + "ISO15118_NONE", + "ISO15118_PNC", + "PLMN_NONE", + "PLMN_RING", + "PLMN_SMS" + ] + } + }, + "IT": { + "description": "Identification-Type: type of the identification data carried in ID.", + "type": "string", + "enum": [ + "NONE", + "DENIED", + "UNDEFINED", + "ISO14443", + "ISO15693", + "EMAID", + "EVCCID", + "EVCOID", + "ISO7812", + "CARD_TXN_NR", + "CENTRAL", + "CENTRAL_1", + "CENTRAL_2", + "LOCAL", + "LOCAL_1", + "LOCAL_2", + "PHONE_NUMBER", + "KEY_CODE" + ] + }, + "ID": { + "description": "Identification-Data: the actual identification data, formatted according to IT, e.g. a hex-encoded UID per ISO 14443.", + "type": "string", + "maxLength": 40 + }, + "CT": { + "description": "Charge-Point-Identification-Type: type of the charge point identification carried in CI.", + "type": "string", + "enum": [ + "EVSEID", + "CBIDC" + ] + }, + "CI": { + "description": "Charge-Point-Identification: identification of the charge point, formatted according to CT.", + "type": "string" + }, + "RD": { + "description": "Readings: one or more meter readings.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ReadingType" + } + } + }, + "required": [ + "FV", + "PG", + "RD" + ], + "dependentRequired": { + "MV": [ + "MS" + ], + "MM": [ + "MS" + ], + "MF": [ + "MS" + ], + "IS": [ + "IT" + ], + "IT": [ + "IS" + ], + "IL": [ + "IS", + "IT" + ], + "IF": [ + "IS", + "IT" + ], + "ID": [ + "IS", + "IT" + ] + } + }, + "ReadingType": { + "description": "A single reading entry within the RD array (section 4.2.6). Fields with a value identical to the previous reading in the array may be omitted.", + "type": "object", + "additionalProperties": false, + "properties": { + "TM": { + "description": "Time: system time of the reading and its synchronization status, ISO 8601 with millisecond resolution followed by a single-letter time-status code.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2},\\d{3}[+-]\\d{4} [UISR]$", + "examples": [ + "2018-07-24T13:22:04,000+0200 S" + ] + }, + "TX": { + "description": "Transaction: reason for the reading in relation to a transaction. B=Begin, C=Charging, X=Exception, E=End, L=Ended locally, R=Ended remotely, A=Aborted, P=Ended by power outage, S=Suspended, T=Tariff change. Absent if there is no transaction reference (Fiscal metering).", + "type": "string", + "enum": [ + "B", + "C", + "X", + "E", + "L", + "R", + "A", + "P", + "S", + "T" + ] + }, + "RV": { + "description": "Reading-Value: the measured value. May be omitted together with RI, RU and RT if the reading only signals a meter error/event.", + "type": "number" + }, + "RI": { + "description": "Reading-Identification: designation of the measurand, as an OBIS code (IEC 62056-6-1/-2). Forms a group with RU: both present or both absent.", + "type": "string" + }, + "RU": { + "description": "Reading-Unit: unit of the reading. Forms a group with RI: both present or both absent.", + "type": "string", + "enum": [ + "kWh", + "Wh" + ] + }, + "RT": { + "description": "Reading-Current-Type: type of current measured by the meter.", + "type": "string", + "enum": [ + "AC", + "DC" + ] + }, + "EF": { + "description": "Error-Flags: which quantities are no longer usable for billing due to an error. 'E' = Energy, 't' = Time.", + "type": "string", + "pattern": "^[Et]*$" + }, + "ST": { + "description": "Status: state of the meter at the time of the reading.", + "type": "string", + "enum": [ + "N", + "G", + "T", + "D", + "R", + "M", + "X", + "I", + "O", + "S", + "E", + "F" + ] + } + }, + "required": [ + "TM", + "ST" + ], + "dependentRequired": { + "RI": [ + "RU" + ], + "RU": [ + "RI" + ] + } + }, + "SignatureType": { + "description": "Signatursektion: the signature over the payload data (section 4.3).", + "type": "object", + "patternProperties": { + "^[UVWXYZABCDEF]": {} + }, + "properties": { + "SA": { + "description": "Signature-Algorithm: algorithm (including curve and hash) used to create the signature. Defaults to ECDSA-secp256r1-SHA256 if omitted.", + "type": "string", + "enum": [ + "ECDSA-secp192k1-SHA256", + "ECDSA-secp256k1-SHA256", + "ECDSA-secp192r1-SHA256", + "ECDSA-secp256r1-SHA256", + "ECDSA-brainpool256r1-SHA256", + "ECDSA-secp384r1-SHA256", + "ECDSA-brainpool384r1-SHA256" + ] + }, + "SE": { + "description": "Signature-Encoding: encoding of SD. Defaults to 'hex' if omitted.", + "type": "string", + "enum": [ + "hex", + "base64" + ] + }, + "SM": { + "description": "Signature-Mime-Type: how to interpret the signature data. Defaults to 'application/x-der' if omitted.", + "type": "string", + "enum": [ + "application/x-der" + ] + }, + "SD": { + "description": "Signature-Data: the actual signature data, encoded per SE and formatted per SM.", + "type": "string" + } + }, + "required": [ + "SD" + ] + } + } +} diff --git a/pkg/ocmf/ocmf.go b/pkg/ocmf/ocmf.go new file mode 100644 index 0000000..9576316 --- /dev/null +++ b/pkg/ocmf/ocmf.go @@ -0,0 +1,78 @@ +// Package ocmf detects and validates Open Charge Metering Format (OCMF) records +package ocmf + +import ( + "embed" + "encoding/json" + "strings" + + "github.com/kaptinlin/jsonschema" + "github.com/pkg/errors" +) + +//go:embed OCMF.json +var schemaFile embed.FS + +// Header is the literal string that identifies an OCMF record on the wire. +const Header = "OCMF" + +var schema *jsonschema.Schema + +func init() { + data, err := schemaFile.ReadFile("OCMF.json") + if err != nil { + panic(errors.Wrap(err, "unable to read embedded OCMF schema")) + } + + schema, err = jsonschema.NewCompiler().Compile(data) + if err != nil { + panic(errors.Wrap(err, "unable to compile embedded OCMF schema")) + } +} + +// LooksLikeOCMF reports whether value appears to be an OCMF record, i.e. it starts +// with the "OCMF|" header used to identify the transfer format. +func LooksLikeOCMF(value string) bool { + return strings.HasPrefix(value, Header+"|") +} + +// Parse splits a raw OCMF record ("OCMF||") into its header, +// payload and signature sections and unmarshals the payload and signature JSON +// objects into their typed Go representations. +func Parse(value string) (*Record, error) { + sections := strings.SplitN(value, "|", 3) + if len(sections) != 3 { + return nil, errors.Errorf("malformed OCMF record: expected 3 '|'-separated sections, got %d", len(sections)) + } + + var payload Payload + if err := json.Unmarshal([]byte(sections[1]), &payload); err != nil { + return nil, errors.Wrap(err, "unable to parse OCMF payload section as JSON") + } + + var signature Signature + if err := json.Unmarshal([]byte(sections[2]), &signature); err != nil { + return nil, errors.Wrap(err, "unable to parse OCMF signature section as JSON") + } + + return &Record{ + Header: sections[0], + Payload: payload, + Signature: signature, + }, nil +} + +// Validate parses value as an OCMF record and validates it against the OCMF JSON Schema. +func Validate(value string) (*jsonschema.EvaluationResult, error) { + record, err := Parse(value) + if err != nil { + return nil, err + } + + data, err := json.Marshal(record) + if err != nil { + return nil, errors.Wrap(err, "unable to marshal parsed OCMF record") + } + + return schema.ValidateJSON(data), nil +} diff --git a/pkg/ocmf/ocmf_test.go b/pkg/ocmf/ocmf_test.go new file mode 100644 index 0000000..2c71d18 --- /dev/null +++ b/pkg/ocmf/ocmf_test.go @@ -0,0 +1,86 @@ +package ocmf + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validRecord is the worked example from the OCMF v1.0.1-DRAFT spec (section 4.5). +const validRecord = `OCMF|{` + + `"FV":"1.0","GI":"ABL SBC-301","GS":"808829900001","GV":"1.4p3","PG":"T12345",` + + `"MV":"Phoenix Contact","MM":"EEM-350-D-MCB","MS":"BQ27400330016","MF":"1.0",` + + `"IS":true,"IL":"VERIFIED","IF":["RFID_PLAIN","OCPP_RS_TLS"],"IT":"ISO14443","ID":"1F2D3A4F5506C7",` + + `"RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","TX":"B","RV":2935.6,"RI":"1-b:1.8.0","RU":"kWh","RT":"AC","EF":"","ST":"G"}]` + + `}|{"SD":"887FABF407AC82782EEFFF2220C2F856AEB0BC22364BBCC6B55761911ED651D1A922BADA88818C9671AFEE7094D7F536"}` + +func TestLooksLikeOCMF(t *testing.T) { + assert.True(t, LooksLikeOCMF(validRecord)) + assert.False(t, LooksLikeOCMF("1234.5")) + assert.False(t, LooksLikeOCMF("")) + assert.False(t, LooksLikeOCMF("OCMFwithoutpipe")) +} + +func TestParse(t *testing.T) { + record, err := Parse(validRecord) + require.NoError(t, err) + assert.Equal(t, "OCMF", record.Header) + assert.Equal(t, "1.0", record.Payload.FV) + assert.Equal(t, "T12345", record.Payload.PG) + require.Len(t, record.Payload.RD, 1) + assert.Equal(t, "G", record.Payload.RD[0].ST) + assert.Equal(t, "887FABF407AC82782EEFFF2220C2F856AEB0BC22364BBCC6B55761911ED651D1A922BADA88818C9671AFEE7094D7F536", record.Signature.SD) + + _, err = Parse("OCMF|{}") + assert.Error(t, err, "expected error for record with less than 3 sections") + + _, err = Parse("OCMF|not-json|{}") + assert.Error(t, err, "expected error for non-JSON payload section") + + _, err = Parse("OCMF|{}|not-json") + assert.Error(t, err, "expected error for non-JSON signature section") +} + +func TestValidate_ValidRecord(t *testing.T) { + result, err := Validate(validRecord) + require.NoError(t, err) + assert.True(t, result.IsValid(), "expected spec example to be valid, got errors: %v", result.Errors) +} + +func TestValidate_InvalidRecord(t *testing.T) { + tests := []struct { + name string + record string + }{ + { + name: "RI without paired RU", + record: `OCMF|{"FV":"1.0","PG":"T1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","ST":"G","RI":"1-b:1.8.0"}]}|{"SD":"AA"}`, + }, + { + name: "missing required ST in reading", + record: `OCMF|{"FV":"1.0","PG":"T1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S"}]}|{"SD":"AA"}`, + }, + { + name: "malformed pagination", + record: `OCMF|{"FV":"1.0","PG":"X1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","ST":"G"}]}|{"SD":"AA"}`, + }, + { + name: "missing signature data", + record: `OCMF|{"FV":"1.0","PG":"T1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","ST":"G"}]}|{}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Validate(tt.record) + require.NoError(t, err) + assert.False(t, result.IsValid()) + }) + } +} + +func TestValidate_MalformedRecord(t *testing.T) { + _, err := Validate("not-an-ocmf-record") + assert.Error(t, err) +} diff --git a/pkg/ocmf/types.go b/pkg/ocmf/types.go new file mode 100644 index 0000000..602d93f --- /dev/null +++ b/pkg/ocmf/types.go @@ -0,0 +1,49 @@ +package ocmf + +// Record is a fully parsed OCMF record: "OCMF||", split on +// its header and the two JSON sections. +type Record struct { + Header string `json:"header"` + Payload Payload `json:"payload"` + Signature Signature `json:"signature"` +} + +// Payload is the signed payload data. +type Payload struct { + FV string `json:"FV,omitempty"` + GI string `json:"GI,omitempty"` + GS string `json:"GS,omitempty"` + GV string `json:"GV,omitempty"` + PG string `json:"PG,omitempty"` + MV string `json:"MV,omitempty"` + MM string `json:"MM,omitempty"` + MS string `json:"MS,omitempty"` + MF string `json:"MF,omitempty"` + IS *bool `json:"IS,omitempty"` + IL string `json:"IL,omitempty"` + IF []string `json:"IF,omitempty"` + IT string `json:"IT,omitempty"` + ID string `json:"ID,omitempty"` + CT string `json:"CT,omitempty"` + CI string `json:"CI,omitempty"` + RD []Reading `json:"RD,omitempty"` +} + +type Reading struct { + TM string `json:"TM,omitempty"` + TX string `json:"TX,omitempty"` + RV *float64 `json:"RV,omitempty"` + RI string `json:"RI,omitempty"` + RU string `json:"RU,omitempty"` + RT string `json:"RT,omitempty"` + EF string `json:"EF,omitempty"` + ST string `json:"ST,omitempty"` +} + +// Signature is the signature over the payload data. +type Signature struct { + SA string `json:"SA,omitempty"` + SE string `json:"SE,omitempty"` + SM string `json:"SM,omitempty"` + SD string `json:"SD,omitempty"` +} diff --git a/pkg/ocpp/meter_value.go b/pkg/ocpp/meter_value.go new file mode 100644 index 0000000..a0d0861 --- /dev/null +++ b/pkg/ocpp/meter_value.go @@ -0,0 +1,32 @@ +package ocpp + +// MeterValuesRequest is the minimal shape of a MeterValues.req payload, shared by +// OCPP 1.6 and 2.0.1/2.1, needed to locate signed meter readings. +type MeterValuesRequest struct { + MeterValue []MeterValue `json:"meterValue"` +} + +// MeterValue is a single entry of MeterValuesRequest.MeterValue. +type MeterValue struct { + SampledValue []SampledValue `json:"sampledValue"` +} + +// SampledValue is a single entry of MeterValue.SampledValue. +// +// OCPP 1.6 declares "value" as a string; OCPP 2.0.1/2.1 declare it as a number. Value is +// typed as any here so one type covers both; callers type-assert to the shape they expect. +type SampledValue struct { + Value any `json:"value,omitempty"` + + // SignedMeterValue is only present in OCPP 2.0.1/2.1, which carry a signed reading + // in this dedicated sub-object rather than directly in Value. + SignedMeterValue *SignedMeterValue `json:"signedMeterValue,omitempty"` +} + +// SignedMeterValue mirrors OCPP 2.0.1/2.1's SignedMeterValueType. +type SignedMeterValue struct { + // SignedMeterData is base64-encoded, in the format named by EncodingMethod. + SignedMeterData string `json:"signedMeterData,omitempty"` + // EncodingMethod names the format used to produce SignedMeterData, e.g. "OCMF", "EDL". + EncodingMethod string `json:"encodingMethod,omitempty"` +} diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 1c629cb..5eb5624 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -2,15 +2,27 @@ package validator import ( "context" + "encoding/base64" + "encoding/json" "fmt" "github.com/pkg/errors" "go.uber.org/zap" + "github.com/ChargePi/chargeflow/pkg/ocmf" "github.com/ChargePi/chargeflow/pkg/ocpp" "github.com/ChargePi/chargeflow/pkg/schema_registry" ) +const ( + // meterValuesAction is the OCPP action name for MeterValues.req. + meterValuesAction = "MeterValues" + // transactionEventAction is the OCPP 2.0.1/2.1 action name for TransactionEvent.req, + // which carries an optional top-level "meterValue" array of the same shape as + // MeterValues.req rather than always requiring a dedicated MeterValues.req. + transactionEventAction = "TransactionEvent" +) + var ErrCannotCastToCallError = errors.New("cannot cast message to CallError") type Validator struct { @@ -56,6 +68,13 @@ func (v *Validator) ValidateMessage(octx ocpp.OcppContext, message ocpp.Message) return result, errors.Wrap(err, "unable to validate message payload") } + switch { + case octx.Version == ocpp.V16 && action == meterValuesAction: + v.validateOCMFSampledValues(payload, result) + case octx.Version == ocpp.V20 && (action == meterValuesAction || action == transactionEventAction): + v.validateOCMFSignedMeterValues(payload, result) + } + case ocpp.SEND: if octx.Version != ocpp.V21 { result.AddError("SEND messages are only supported in OCPP 2.1") @@ -141,3 +160,102 @@ func (v *Validator) validatePayload( return nil } + +// decodeMeterValuesPayload re-decodes a generically-parsed payload carrying a top-level +// "meterValue" array (MeterValues.req, and OCPP 2.0.1/2.1's TransactionEvent.req) into +// OCPP's own MeterValue/SampledValue/SignedMeterValue types so callers can work with +// named fields instead of raw map lookups. +func decodeMeterValuesPayload(payload interface{}) (*ocpp.MeterValuesRequest, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "unable to marshal MeterValues payload") + } + + var decoded ocpp.MeterValuesRequest + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, errors.Wrap(err, "unable to decode MeterValues payload") + } + + return &decoded, nil +} + +// validateOCMFSampledValues scans an OCPP 1.6 MeterValues.req payload for +// sampledValue.value entries carrying an OCMF-formatted signed meter reading and +// validates each one found against the OCMF JSON Schema, in addition to the regular +// OCPP schema validation. +func (v *Validator) validateOCMFSampledValues(payload interface{}, validationResults *ValidationResult) { + logger := v.logger.Named("ocmf") + + decoded, err := decodeMeterValuesPayload(payload) + if err != nil { + logger.Debug("unable to decode MeterValues payload for OCMF detection", zap.Error(err)) + return + } + + for _, meterValue := range decoded.MeterValue { + for _, sampledValue := range meterValue.SampledValue { + value, ok := sampledValue.Value.(string) + if !ok || !ocmf.LooksLikeOCMF(value) { + continue + } + + logger.Debug("found OCMF-formatted sampledValue.value") + v.validateOCMFRecord(logger, value, validationResults) + } + } +} + +// validateOCMFSignedMeterValues scans an OCPP 2.0.1 MeterValues.req or TransactionEvent.req +// payload for sampledValue.signedMeterValue entries whose encodingMethod is "OCMF". +// signedMeterData is base64-encoded per the OCPP spec (Figure 2 / Table 12) and must be +// decoded before it can be validated as an OCMF record. +func (v *Validator) validateOCMFSignedMeterValues(payload interface{}, validationResults *ValidationResult) { + logger := v.logger.Named("ocmf") + + decoded, err := decodeMeterValuesPayload(payload) + if err != nil { + logger.Debug("unable to decode MeterValues payload for OCMF detection", zap.Error(err)) + return + } + + for _, meterValue := range decoded.MeterValue { + for _, sampledValue := range meterValue.SampledValue { + signed := sampledValue.SignedMeterValue + if signed == nil || signed.EncodingMethod != ocmf.Header { + continue + } + + logger.Debug("found OCMF-encoded signedMeterValue", zap.String("encodingMethod", signed.EncodingMethod)) + + raw, err := base64.StdEncoding.DecodeString(signed.SignedMeterData) + if err != nil { + logger.Warn("signedMeterValue.signedMeterData is declared as OCMF but is not valid base64", zap.Error(err)) + validationResults.AddError(fmt.Sprintf("signedMeterValue.signedMeterData is declared as OCMF (encodingMethod) but is not valid base64: %s", err)) + continue + } + + v.validateOCMFRecord(logger, string(raw), validationResults) + } + } +} + +// validateOCMFRecord validates a single raw OCMF record against the OCMF JSON Schema and +// appends any failures to validationResults. +func (v *Validator) validateOCMFRecord(logger *zap.Logger, record string, validationResults *ValidationResult) { + evaluationResult, err := ocmf.Validate(record) + if err != nil { + logger.Warn("OCMF record could not be parsed", zap.Error(err)) + validationResults.AddError(fmt.Sprintf("sampledValue contains an OCMF record that could not be parsed: %s", err)) + return + } + + if !evaluationResult.IsValid() { + logger.Debug("OCMF record failed schema validation", zap.Int("errors", len(evaluationResult.Errors))) + for _, evaluationError := range evaluationResult.Errors { + validationResults.AddError(fmt.Sprintf("OCMF: %s", evaluationError.Error())) + } + return + } + + logger.Debug("OCMF record is valid") +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index 540a66f..34ca3ff 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -1,6 +1,8 @@ package validator import ( + "encoding/base64" + "strings" "testing" "github.com/google/uuid" @@ -97,6 +99,58 @@ var responseSchema = []byte(`{ ] }`) +// permissiveSchema accepts any object payload; used where the test cares about the +// OCMF hook rather than full OCPP structural validation. +var permissiveSchema = []byte(`{"type": "object"}`) + +const validOCMFRecord = `OCMF|{"FV":"1.0","PG":"T1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","ST":"G"}]}|{"SD":"AA"}` + +// invalidOCMFRecord has an "RI" reading field without its required pair "RU". +const invalidOCMFRecord = `OCMF|{"FV":"1.0","PG":"T1","RD":[{"TM":"2018-07-24T13:22:04,000+0200 S","ST":"G","RI":"1-b:1.8.0"}]}|{"SD":"AA"}` + +// meterValuesPayload16 builds an OCPP 1.6 MeterValues.req payload carrying value as the +// raw sampledValue, as used for signed (e.g. OCMF) readings in that version. +func meterValuesPayload16(value string) map[string]interface{} { + return map[string]interface{}{ + "connectorId": float64(1), + "meterValue": []interface{}{ + map[string]interface{}{ + "timestamp": "2023-10-01T12:00:00Z", + "sampledValue": []interface{}{ + map[string]interface{}{ + "value": value, + "format": "SignedData", + }, + }, + }, + }, + } +} + +// meterValuesPayload20 builds an OCPP 2.0.1 MeterValues.req payload carrying value inside +// a sampledValue.signedMeterValue sub-object, base64-encoding it as the OCPP spec requires. +func meterValuesPayload20(encodingMethod, value string) map[string]interface{} { + return map[string]interface{}{ + "evseId": float64(1), + "meterValue": []interface{}{ + map[string]interface{}{ + "timestamp": "2023-10-01T12:00:00Z", + "sampledValue": []interface{}{ + map[string]interface{}{ + "value": float64(0), + "signedMeterValue": map[string]interface{}{ + "signedMeterData": base64.StdEncoding.EncodeToString([]byte(value)), + "signingMethod": "", + "encodingMethod": encodingMethod, + "publicKey": "", + }, + }, + }, + }, + }, + } +} + type validatorTestSuite struct { suite.Suite logger *zap.Logger @@ -473,6 +527,118 @@ func (s *validatorTestSuite) TestValidateMessage_UnhappyPath() { } } +func (s *validatorTestSuite) TestValidateMessage_MeterValuesOCMF() { + tests := []struct { + name string + ocppCtx ocpp.OcppContext + action string + payload interface{} + expectOCMF bool + wantErrText string + }{ + { + name: "OCPP 1.6 MeterValues with valid OCMF sampled value", + ocppCtx: ocpp.OcppContext{Version: ocpp.V16}, + action: "MeterValues", + payload: meterValuesPayload16(validOCMFRecord), + expectOCMF: false, + }, + { + name: "OCPP 1.6 MeterValues with invalid OCMF sampled value", + ocppCtx: ocpp.OcppContext{Version: ocpp.V16}, + action: "MeterValues", + payload: meterValuesPayload16(invalidOCMFRecord), + expectOCMF: true, + wantErrText: "OCMF:", + }, + { + name: "OCPP 1.6 MeterValues with non-OCMF sampled value", + ocppCtx: ocpp.OcppContext{Version: ocpp.V16}, + action: "MeterValues", + payload: meterValuesPayload16("1234.5"), + expectOCMF: false, + }, + { + name: "OCPP 2.0 MeterValues with valid OCMF signedMeterValue", + ocppCtx: ocpp.OcppContext{Version: ocpp.V20}, + action: "MeterValues", + payload: meterValuesPayload20("OCMF", validOCMFRecord), + expectOCMF: false, + }, + { + name: "OCPP 2.0 MeterValues with invalid OCMF signedMeterValue", + ocppCtx: ocpp.OcppContext{Version: ocpp.V20}, + action: "MeterValues", + payload: meterValuesPayload20("OCMF", invalidOCMFRecord), + expectOCMF: true, + wantErrText: "OCMF:", + }, + { + name: "OCPP 2.0 MeterValues with non-OCMF encodingMethod is not checked", + ocppCtx: ocpp.OcppContext{Version: ocpp.V20}, + action: "MeterValues", + payload: meterValuesPayload20("EDL", invalidOCMFRecord), + expectOCMF: false, + }, + { + name: "OCPP 1.6 MeterValues is not checked for OCPP 2.0's signedMeterValue shape", + ocppCtx: ocpp.OcppContext{Version: ocpp.V16}, + action: "MeterValues", + payload: meterValuesPayload20("OCMF", invalidOCMFRecord), + expectOCMF: false, + }, + { + name: "OCPP 2.0 TransactionEvent with invalid OCMF signedMeterValue", + ocppCtx: ocpp.OcppContext{Version: ocpp.V20}, + action: "TransactionEvent", + payload: meterValuesPayload20("OCMF", invalidOCMFRecord), + expectOCMF: true, + wantErrText: "OCMF:", + }, + { + name: "OCPP 2.0 TransactionEvent without meterValue is not checked", + ocppCtx: ocpp.OcppContext{Version: ocpp.V20}, + action: "TransactionEvent", + payload: map[string]interface{}{"eventType": "Started"}, + expectOCMF: false, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + registry := mock_schema_registry.NewMockSchemaRegistry(s.T()) + schemaFromCompiler, err := s.compiler.Compile(permissiveSchema) + s.Require().NoError(err) + registry.EXPECT().GetSchema(mock.Anything, schema_registry.GetSchemaRequest{OcppContext: tt.ocppCtx, Action: tt.action + "Request"}).Return(schemaFromCompiler, true) + + validator := NewValidator(s.logger, registry) + + result, err := validator.ValidateMessage(tt.ocppCtx, &ocpp.Call{ + MessageTypeId: ocpp.CALL, + UniqueId: uuid.NewString(), + Action: tt.action, + Payload: tt.payload, + }) + s.Require().NoError(err) + + if !tt.expectOCMF { + s.Emptyf(result.Errors(), "expected no validation errors but got %v", result.Errors()) + return + } + + s.NotEmpty(result.Errors()) + found := false + for _, e := range result.Errors() { + if strings.Contains(e, tt.wantErrText) { + found = true + break + } + } + s.Truef(found, "expected an error containing %q, got %v", tt.wantErrText, result.Errors()) + }) + } +} + func TestValidator(t *testing.T) { suite.Run(t, new(validatorTestSuite)) }