From 4402c9ed25aa218017bdd55b1dc13e83cb8ed64d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 11:47:24 +0300 Subject: [PATCH] feat(data-plane): add bounded Gelios telemetry contract --- .../build-external-data-plane-artifact.mjs | 1 + .../providers/gelios/v7/README.md | 11 + .../providers/gelios/v7/index.mjs | 9 + .../providers/gelios/v7/package.mjs | 214 ++++++++++++++++++ .../src/data-plane.mjs | 5 + .../external-provider-contract/src/index.mjs | 5 + .../src/provider-package.mjs | 8 +- .../src/telemetry-readings.mjs | 48 ++++ .../test/provider-package.test.mjs | 23 ++ services/external-data-plane/Dockerfile | 1 + .../fleet.positions.current.v5.json | 49 ++++ .../src/data-product-delivery.mjs | 2 + .../src/data-product-policy.mjs | 7 +- .../test/data-product-delivery.test.mjs | 34 +++ .../test/data-product-policy.test.mjs | 9 + .../test/definitions.test.mjs | 19 +- 16 files changed, 432 insertions(+), 13 deletions(-) create mode 100644 packages/external-provider-contract/providers/gelios/v7/README.md create mode 100644 packages/external-provider-contract/providers/gelios/v7/index.mjs create mode 100644 packages/external-provider-contract/providers/gelios/v7/package.mjs create mode 100644 packages/external-provider-contract/src/telemetry-readings.mjs create mode 100644 services/external-data-plane/definitions/fleet.positions.current.v5.json diff --git a/infra/deploy-runner/build-external-data-plane-artifact.mjs b/infra/deploy-runner/build-external-data-plane-artifact.mjs index 714d00c..a4b10d1 100644 --- a/infra/deploy-runner/build-external-data-plane-artifact.mjs +++ b/infra/deploy-runner/build-external-data-plane-artifact.mjs @@ -27,6 +27,7 @@ const files = [ ["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"], ["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"], ["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"], + ["packages/external-provider-contract/src/telemetry-readings.mjs", "platform/packages/external-provider-contract/src/telemetry-readings.mjs"], ["packages/external-provider-contract/src/zone-source.mjs", "platform/packages/external-provider-contract/src/zone-source.mjs"], ["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"], ]; diff --git a/packages/external-provider-contract/providers/gelios/v7/README.md b/packages/external-provider-contract/providers/gelios/v7/README.md new file mode 100644 index 0000000..0223810 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v7/README.md @@ -0,0 +1,11 @@ +# Gelios provider package v7 + +Version 7 preserves the v6 geozone capability and introduces the immutable +`fleet.positions.current.v5@5.0.0` telemetry contract. + +The current-units request now asks Gelios for counters, sensor definitions and +latest sensor values. The mapping publishes only provider-neutral, bounded +telemetry: mileage, engine hours and at most 128 scalar sensor readings with a +stable id, label, optional unit and observation time. IMEI, phones, decrypt +keys, addresses, raw message params, raw sensor definitions and secret-like +material remain outside the product. diff --git a/packages/external-provider-contract/providers/gelios/v7/index.mjs b/packages/external-provider-contract/providers/gelios/v7/index.mjs new file mode 100644 index 0000000..6b56a10 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v7/index.mjs @@ -0,0 +1,9 @@ +export { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_PROVIDER_PACKAGE_ID, + GELIOS_PROVIDER_PACKAGE_VERSION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV7, +} from "./package.mjs"; diff --git a/packages/external-provider-contract/providers/gelios/v7/package.mjs b/packages/external-provider-contract/providers/gelios/v7/package.mjs new file mode 100644 index 0000000..03c4df0 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v7/package.mjs @@ -0,0 +1,214 @@ +import { geliosProviderPackageV6 } from "../v6/package.mjs"; + +export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v7"; +export const GELIOS_PROVIDER_PACKAGE_VERSION = "7.0.0"; +export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v5"; +export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "5.0.0"; +export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3"; +export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-"; + +const PREVIOUS_PRODUCT_ID = "fleet.positions.current.v4"; +const PREVIOUS_FIELD_POLICY_ID = "gelios.positions.current.fields.v4"; +const PREVIOUS_REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v5"; +const PREVIOUS_MANUAL_PROFILE_ID = "gelios.positions.current.manual.v5"; +const PREVIOUS_MAPPING_ID = "gelios.units.to.fleet.positions.current.v5"; +const PREVIOUS_TEMPLATE_ID = "gelios.positions.current.l2.v5"; +const FIELD_POLICY_ID = "gelios.positions.current.fields.v5"; +const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v7"; +const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v7"; +const MAPPING_ID = "gelios.units.to.fleet.positions.current.v7"; +const TEMPLATE_ID = "gelios.positions.current.l2.v7"; + +const positionFields = Object.freeze([ + "course_degrees", + "display_name", + "elevation_meters", + "engine_hours", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "mileage_km", + "movement_state", + "object_kind", + "position_source", + "satellite_count", + "sensor_readings", + "signal_state", + "speed_kph", +]); + +const value = structuredClone(geliosProviderPackageV6); +const previousMapping = value.mappingContracts.find((mapping) => mapping.id === PREVIOUS_MAPPING_ID); +if (!previousMapping) throw new Error("gelios_v7_previous_mapping_missing"); + +value.id = GELIOS_PROVIDER_PACKAGE_ID; +value.version = GELIOS_PROVIDER_PACKAGE_VERSION; +value.manifest = { + ...value.manifest, + id: "gelios.provider.manifest.v7", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + fieldPolicyIds: replaceOne(value.manifest.fieldPolicyIds, PREVIOUS_FIELD_POLICY_ID, FIELD_POLICY_ID), + collectionProfileIds: replaceOne( + replaceOne(value.manifest.collectionProfileIds, PREVIOUS_REALTIME_PROFILE_ID, REALTIME_PROFILE_ID), + PREVIOUS_MANUAL_PROFILE_ID, + MANUAL_PROFILE_ID, + ), + dataProductIds: replaceOne(value.manifest.dataProductIds, PREVIOUS_PRODUCT_ID, GELIOS_POSITIONS_DATA_PRODUCT_ID), + mappingContractIds: replaceOne(value.manifest.mappingContractIds, PREVIOUS_MAPPING_ID, MAPPING_ID), + l2TemplateIds: replaceOne(value.manifest.l2TemplateIds, PREVIOUS_TEMPLATE_ID, TEMPLATE_ID), +}; + +value.capabilities = value.capabilities.map((capability) => ( + capability.id === "gelios.units.current.read" + ? { + ...capability, + request: { + ...capability.request, + query: { + ...capability.request.query, + incltrip: "true", + inclcntrs: "true", + inclsnsrs: "true", + incllsv: "true", + }, + }, + } + : capability +)); + +value.fieldPolicies = value.fieldPolicies.map((policy) => ( + policy.id === PREVIOUS_FIELD_POLICY_ID + ? { + ...policy, + id: FIELD_POLICY_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + targetFields: [...positionFields], + restrictedSourcePaths: policy.restrictedSourcePaths + .filter((path) => path !== "sensors") + .concat(["sensors.conversionTable"]), + } + : policy +)); + +value.collectionProfiles = value.collectionProfiles.map((profile) => { + if (profile.dataProductId !== PREVIOUS_PRODUCT_ID) return profile; + return { + ...profile, + id: profile.mode === "realtime" ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: MAPPING_ID, + fieldPolicyId: FIELD_POLICY_ID, + l2TemplateId: TEMPLATE_ID, + }; +}); + +value.dataProducts = value.dataProducts.map((product) => ( + product.id === PREVIOUS_PRODUCT_ID + ? { + id: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...positionFields], + fieldContracts: { + course_degrees: { type: "number", required: false, minimum: 0, maximum: 360 }, + display_name: { type: "string", required: true }, + elevation_meters: { type: "number", required: false }, + engine_hours: { type: "number", required: false, minimum: 0 }, + geometry: { type: "point", required: false }, + hdop: { type: "number", required: false, minimum: 0 }, + horizontal_accuracy_meters: { type: "number", required: false, minimum: 0 }, + mileage_km: { type: "number", required: false, minimum: 0 }, + movement_state: { type: "string", required: true, enum: ["moving", "stopped"] }, + object_kind: { type: "string", required: true }, + position_source: { type: "string", required: true }, + satellite_count: { type: "number", required: false, minimum: 0 }, + sensor_readings: { type: "telemetry_readings", required: false }, + signal_state: { type: "string", required: true, enum: ["active", "inactive"] }, + speed_kph: { type: "number", required: false, minimum: 0 }, + }, + history: { mode: "sampled", intervalMs: 60_000, strategy: "latest-per-entity-per-bucket", retentionDays: 90 }, + } + : product +)); + +value.mappingContracts = value.mappingContracts.map((mapping) => { + if (mapping.id !== PREVIOUS_MAPPING_ID) return mapping; + return { + ...mapping, + id: MAPPING_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + fieldPolicyId: FIELD_POLICY_ID, + target: { + ...mapping.target, + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + }, + derivations: { + ...mapping.derivations, + sensor_readings: { + kind: "bounded_readings", + rules: ["last_sensor_values.normalized"], + default: [], + parameters: { maxReadings: 128 }, + }, + }, + fact: { + ...mapping.fact, + attributes: { + ...mapping.fact.attributes, + engine_hours: { + strategy: "first_non_empty", + paths: ["counters.engineHours.value"], + coerce: "number", + minimum: 0, + omitIfMissing: true, + omitIfInvalid: true, + }, + mileage_km: { + strategy: "first_non_empty", + paths: ["counters.mileage.value"], + coerce: "number", + minimum: 0, + omitIfMissing: true, + omitIfInvalid: true, + }, + sensor_readings: { derive: "sensor_readings" }, + }, + }, + }; +}); + +value.l2Templates = value.l2Templates.map((template) => { + if (template.id !== PREVIOUS_TEMPLATE_ID) return template; + return { + ...template, + id: TEMPLATE_ID, + version: GELIOS_PROVIDER_PACKAGE_VERSION, + steps: template.steps.map((step) => { + if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID }; + if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID }; + return step; + }), + }; +}); + +export const geliosProviderPackageV7 = deepFreeze(value); + +function replaceOne(items, previous, next) { + if (!Array.isArray(items) || items.filter((item) => item === previous).length !== 1) { + throw new Error(`gelios_v7_manifest_predecessor_invalid:${previous}`); + } + return items.map((item) => item === previous ? next : item); +} + +function deepFreeze(input) { + if (!input || typeof input !== "object" || Object.isFrozen(input)) return input; + Object.freeze(input); + for (const child of Object.values(input)) deepFreeze(child); + return input; +} diff --git a/packages/external-provider-contract/src/data-plane.mjs b/packages/external-provider-contract/src/data-plane.mjs index 15d2ee3..020d232 100644 --- a/packages/external-provider-contract/src/data-plane.mjs +++ b/packages/external-provider-contract/src/data-plane.mjs @@ -7,6 +7,11 @@ export { isBoundedGeoJsonGeometry, validateGeoJsonGeometry, } from "./geometry.mjs"; +export { + TELEMETRY_READINGS_MAX_BYTES, + TELEMETRY_READINGS_MAX_ITEMS, + isBoundedTelemetryReadings, +} from "./telemetry-readings.mjs"; export { DATA_PRODUCT_HISTORY_SCHEMA_VERSION, DATA_PRODUCT_PATCH_SCHEMA_VERSION, diff --git a/packages/external-provider-contract/src/index.mjs b/packages/external-provider-contract/src/index.mjs index 2656aaf..1f0e377 100644 --- a/packages/external-provider-contract/src/index.mjs +++ b/packages/external-provider-contract/src/index.mjs @@ -9,6 +9,11 @@ export { isBoundedGeoJsonGeometry, validateGeoJsonGeometry, } from "./geometry.mjs"; +export { + TELEMETRY_READINGS_MAX_BYTES, + TELEMETRY_READINGS_MAX_ITEMS, + isBoundedTelemetryReadings, +} from "./telemetry-readings.mjs"; export { ZONE_SOURCE_ADAPTERS, ZONE_SOURCE_GENERATION_SCHEMA_VERSION, diff --git a/packages/external-provider-contract/src/provider-package.mjs b/packages/external-provider-contract/src/provider-package.mjs index bd85fab..08561c9 100644 --- a/packages/external-provider-contract/src/provider-package.mjs +++ b/packages/external-provider-contract/src/provider-package.mjs @@ -4,6 +4,7 @@ export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-insta export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1"; import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs"; +import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; @@ -20,7 +21,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]); -const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]); +const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "telemetry_readings", "point", "geometry"]); const L2_STEP_KINDS = new Set([ "collection_trigger", "provider_request", @@ -871,7 +872,7 @@ function validateFieldContract(value, path, errors) { if (value.enum !== undefined) { if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) { errors.push(`${path}.enum_must_be_nonempty_unique_array`); - } else if (new Set(["point", "geometry", "string_array"]).has(value.type) + } else if (new Set(["point", "geometry", "string_array", "telemetry_readings"]).has(value.type) || value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) { errors.push(`${path}.enum_value_type_invalid`); } @@ -939,6 +940,7 @@ function validateMappedValueAgainstFieldContract(value, contract, path, errors) function fieldContractValueMatchesType(value, type) { if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); + if (type === "telemetry_readings") return isBoundedTelemetryReadings(value); if (type === "point") { return isPlainObject(value) && value.type === "Point" @@ -997,7 +999,7 @@ function validateExpression(value, path, errors) { function validateDerivation(value, path, errors) { if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); rejectUnknownKeys(value, DERIVATION_KEYS, path, errors); - if (!new Set(["boolean_rule", "ordered_rules", "flag_set"]).has(value.kind)) errors.push(`${path}.kind_invalid`); + if (!new Set(["boolean_rule", "ordered_rules", "flag_set", "bounded_readings"]).has(value.kind)) errors.push(`${path}.kind_invalid`); requiredUniqueIdentifierArray(value.rules, `${path}.rules`, errors); if (value.default === undefined) { errors.push(`${path}.default_required`); diff --git a/packages/external-provider-contract/src/telemetry-readings.mjs b/packages/external-provider-contract/src/telemetry-readings.mjs new file mode 100644 index 0000000..f2fdf1f --- /dev/null +++ b/packages/external-provider-contract/src/telemetry-readings.mjs @@ -0,0 +1,48 @@ +export const TELEMETRY_READINGS_MAX_ITEMS = 128; +export const TELEMETRY_READINGS_MAX_BYTES = 128 * 1024; + +const READING_KEYS = new Set(["id", "label", "value", "unit", "observedAt"]); +const READING_ID = /^[a-z][a-z0-9._:-]{1,127}$/; + +/** + * Validate the provider-neutral bounded telemetry collection accepted inside + * one current-position fact. Provider-specific payloads, raw sensor objects + * and arbitrary recursive JSON are deliberately excluded. + */ +export function isBoundedTelemetryReadings(value) { + if (!Array.isArray(value) || value.length > TELEMETRY_READINGS_MAX_ITEMS) return false; + let encoded; + try { + encoded = new TextEncoder().encode(JSON.stringify(value)); + } catch { + return false; + } + if (encoded.byteLength > TELEMETRY_READINGS_MAX_BYTES) return false; + + const ids = new Set(); + for (const reading of value) { + if (!isPlainObject(reading) || Object.keys(reading).some((key) => !READING_KEYS.has(key))) return false; + if (typeof reading.id !== "string" || !READING_ID.test(reading.id) || ids.has(reading.id)) return false; + ids.add(reading.id); + if (typeof reading.label !== "string" || !reading.label.trim() || reading.label.length > 160) return false; + if (!isScalar(reading.value)) return false; + if (typeof reading.value === "string" && reading.value.length > 512) return false; + if (reading.unit !== undefined && (typeof reading.unit !== "string" || reading.unit.length > 32)) return false; + if (reading.observedAt !== undefined && !isIsoTimestamp(reading.observedAt)) return false; + } + return true; +} + +function isScalar(value) { + return typeof value === "string" + || typeof value === "boolean" + || (typeof value === "number" && Number.isFinite(value)); +} + +function isIsoTimestamp(value) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/external-provider-contract/test/provider-package.test.mjs b/packages/external-provider-contract/test/provider-package.test.mjs index f7f396a..deadbe6 100644 --- a/packages/external-provider-contract/test/provider-package.test.mjs +++ b/packages/external-provider-contract/test/provider-package.test.mjs @@ -19,6 +19,7 @@ import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs"; import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs"; import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs"; import { geliosProviderPackageV6 } from "../providers/gelios/v6/index.mjs"; +import { geliosProviderPackageV7 } from "../providers/gelios/v7/index.mjs"; import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs"; const expectedFields = [ @@ -43,6 +44,28 @@ assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, e assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] }); +assert.deepEqual(validateProviderPackage(geliosProviderPackageV7), { ok: true, errors: [] }); + +const telemetryProduct = geliosProviderPackageV7.dataProducts.find((product) => product.id === "fleet.positions.current.v5"); +const registeredTelemetryProduct = JSON.parse(await readFile(new URL( + "../../../services/external-data-plane/definitions/fleet.positions.current.v5.json", + import.meta.url, +), "utf8")); +assert.deepEqual(telemetryProduct, registeredTelemetryProduct); +assert.equal(telemetryProduct.fieldContracts.sensor_readings.type, "telemetry_readings"); +const telemetryCapability = geliosProviderPackageV7.capabilities.find((capability) => capability.id === "gelios.units.current.read"); +assert.deepEqual(telemetryCapability.request.query, { + incltrip: "true", + inclcntrs: "true", + inclsnsrs: "true", + incllsv: "true", +}); +const telemetryMapping = geliosProviderPackageV7.mappingContracts.find((mapping) => mapping.id === "gelios.units.to.fleet.positions.current.v7"); +assert.equal(telemetryMapping.derivations.sensor_readings.kind, "bounded_readings"); +assert.equal(telemetryMapping.derivations.sensor_readings.parameters.maxReadings, 128); +assert.equal(telemetryMapping.fact.attributes.mileage_km.paths[0], "counters.mileage.value"); +assert.equal(telemetryMapping.fact.attributes.engine_hours.paths[0], "counters.engineHours.value"); +assert.equal(geliosProviderPackageV7.dataProducts.some((product) => product.id === "map.gelios-geofences.current.v1"), true); const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.gelios-geofences.current.v1"); const registeredZonesProduct = JSON.parse(await readFile(new URL( diff --git a/services/external-data-plane/Dockerfile b/services/external-data-plane/Dockerfile index 0ae7369..7cf88b7 100644 --- a/services/external-data-plane/Dockerfile +++ b/services/external-data-plane/Dockerfile @@ -9,6 +9,7 @@ COPY packages/external-provider-contract/src/data-product.mjs ./packages/externa COPY packages/external-provider-contract/src/geometry.mjs ./packages/external-provider-contract/src/geometry.mjs COPY packages/external-provider-contract/src/intake-batch.mjs ./packages/external-provider-contract/src/intake-batch.mjs COPY packages/external-provider-contract/src/sensitive-field-policy.mjs ./packages/external-provider-contract/src/sensitive-field-policy.mjs +COPY packages/external-provider-contract/src/telemetry-readings.mjs ./packages/external-provider-contract/src/telemetry-readings.mjs COPY services/external-data-plane/package.json services/external-data-plane/package-lock.json ./services/external-data-plane/ WORKDIR /workspace/services/external-data-plane diff --git a/services/external-data-plane/definitions/fleet.positions.current.v5.json b/services/external-data-plane/definitions/fleet.positions.current.v5.json new file mode 100644 index 0000000..8ef9ffc --- /dev/null +++ b/services/external-data-plane/definitions/fleet.positions.current.v5.json @@ -0,0 +1,49 @@ +{ + "id": "fleet.positions.current.v5", + "version": "5.0.0", + "ontologyRevision": "ontology.map.moving_object.v3", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fields": [ + "course_degrees", + "display_name", + "elevation_meters", + "engine_hours", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "mileage_km", + "movement_state", + "object_kind", + "position_source", + "satellite_count", + "sensor_readings", + "signal_state", + "speed_kph" + ], + "fieldContracts": { + "course_degrees": { "type": "number", "required": false, "minimum": 0, "maximum": 360 }, + "display_name": { "type": "string", "required": true }, + "elevation_meters": { "type": "number", "required": false }, + "engine_hours": { "type": "number", "required": false, "minimum": 0 }, + "geometry": { "type": "point", "required": false }, + "hdop": { "type": "number", "required": false, "minimum": 0 }, + "horizontal_accuracy_meters": { "type": "number", "required": false, "minimum": 0 }, + "mileage_km": { "type": "number", "required": false, "minimum": 0 }, + "movement_state": { "type": "string", "required": true, "enum": ["moving", "stopped"] }, + "object_kind": { "type": "string", "required": true }, + "position_source": { "type": "string", "required": true }, + "satellite_count": { "type": "number", "required": false, "minimum": 0 }, + "sensor_readings": { "type": "telemetry_readings", "required": false }, + "signal_state": { "type": "string", "required": true, "enum": ["active", "inactive"] }, + "speed_kph": { "type": "number", "required": false, "minimum": 0 } + }, + "history": { + "mode": "sampled", + "intervalMs": 60000, + "strategy": "latest-per-entity-per-bucket", + "retentionDays": 90 + } +} diff --git a/services/external-data-plane/src/data-product-delivery.mjs b/services/external-data-plane/src/data-product-delivery.mjs index 10908ff..5660282 100644 --- a/services/external-data-plane/src/data-product-delivery.mjs +++ b/services/external-data-plane/src/data-product-delivery.mjs @@ -4,6 +4,7 @@ import { DATA_PRODUCT_PATCH_SCHEMA_VERSION, DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, isBoundedGeoJsonGeometry, + isBoundedTelemetryReadings, } from "@nodedc/external-provider-contract/data-plane"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; @@ -854,6 +855,7 @@ function assertFieldContractValue(value, contract) { function fieldContractValueMatchesType(value, type) { if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); + if (type === "telemetry_readings") return isBoundedTelemetryReadings(value); if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"])); if (type === "geometry") return isBoundedGeoJsonGeometry(value); return typeof value === type && (type !== "number" || Number.isFinite(value)); diff --git a/services/external-data-plane/src/data-product-policy.mjs b/services/external-data-plane/src/data-product-policy.mjs index ee21dee..8ebdf42 100644 --- a/services/external-data-plane/src/data-product-policy.mjs +++ b/services/external-data-plane/src/data-product-policy.mjs @@ -1,10 +1,10 @@ -import { isBoundedGeoJsonGeometry } from "@nodedc/external-provider-contract/data-plane"; +import { isBoundedGeoJsonGeometry, isBoundedTelemetryReadings } from "@nodedc/external-provider-contract/data-plane"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]); -const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]); +const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "telemetry_readings", "point", "geometry"]); const DEFINITION_KEYS = new Set([ "id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history", ]); @@ -65,7 +65,7 @@ function normalizeFieldContract(value) { if (value.enum !== undefined) { if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length - || new Set(["point", "geometry", "string_array"]).has(type) + || new Set(["point", "geometry", "string_array", "telemetry_readings"]).has(type) || value.enum.some((item) => !fieldContractValueMatchesType(item, type))) { throw policyError("data_product_field_contract_enum_invalid"); } @@ -174,6 +174,7 @@ function hasOnlyKeys(value, allowed) { function fieldContractValueMatchesType(value, type) { if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); + if (type === "telemetry_readings") return isBoundedTelemetryReadings(value); if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"])); if (type === "geometry") return isBoundedGeoJsonGeometry(value); return typeof value === type && (type !== "number" || Number.isFinite(value)); diff --git a/services/external-data-plane/test/data-product-delivery.test.mjs b/services/external-data-plane/test/data-product-delivery.test.mjs index e84dad1..4fe77f0 100644 --- a/services/external-data-plane/test/data-product-delivery.test.mjs +++ b/services/external-data-plane/test/data-product-delivery.test.mjs @@ -65,6 +65,40 @@ assert.throws( }, definition), (error) => error?.status === 422 && error?.code === "data_product_field_type_invalid", ); + +const telemetryDefinition = normalizeDataProductDefinition({ + id: "test.telemetry.current.v1", + version: "1.0.0", + ontologyRevision: "ontology.test.telemetry.v1", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: ["sensor_readings"], + fieldContracts: { + sensor_readings: { type: "telemetry_readings", required: false }, + }, + history: { mode: "none", retentionDays: 1 }, +}); +const validReadings = [{ + id: "battery.voltage", + label: "Battery voltage", + value: 51.7, + unit: "V", + observedAt: "2026-07-22T08:00:00.000Z", +}]; +assert.doesNotThrow(() => assertPublishMatchesDefinition({ facts: [{ + sourceId: "unit-telemetry", + semanticType: "map.moving_object", + observedAt: "2026-07-22T08:00:00.000Z", + attributes: { sensor_readings: validReadings }, +}] }, telemetryDefinition)); +assert.throws( + () => assertPublishMatchesDefinition({ facts: [{ + sourceId: "unit-telemetry", + semanticType: "map.moving_object", + attributes: { sensor_readings: [{ ...validReadings[0], value: { raw: 51.7 } }] }, + }] }, telemetryDefinition), + (error) => error?.status === 422 && error?.code === "data_product_field_type_invalid", +); assert.throws( () => assertPublishMatchesDefinition({ facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }], diff --git a/services/external-data-plane/test/data-product-policy.test.mjs b/services/external-data-plane/test/data-product-policy.test.mjs index a21b8bf..c701321 100644 --- a/services/external-data-plane/test/data-product-policy.test.mjs +++ b/services/external-data-plane/test/data-product-policy.test.mjs @@ -95,6 +95,15 @@ assert.equal(Object.isFrozen(strict.fieldContracts), true); assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true); assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true); +const telemetry = normalizeDataProductDefinition({ + ...input, + fields: ["sensor_readings"], + fieldContracts: { + sensor_readings: { type: "telemetry_readings", required: false }, + }, +}); +assert.equal(telemetry.fieldContracts.sensor_readings.type, "telemetry_readings"); + assert.throws( () => normalizeDataProductDefinition({ ...input, diff --git a/services/external-data-plane/test/definitions.test.mjs b/services/external-data-plane/test/definitions.test.mjs index 9e9bd1b..2696977 100644 --- a/services/external-data-plane/test/definitions.test.mjs +++ b/services/external-data-plane/test/definitions.test.mjs @@ -11,6 +11,7 @@ assert.deepEqual(bundled.map((definition) => definition.id), [ "fleet.positions.current.v2", "fleet.positions.current.v3", "fleet.positions.current.v4", + "fleet.positions.current.v5", "map.zones.current.v1", "map.zones.current.v2", ]); @@ -47,19 +48,23 @@ assert.deepEqual(bundled[3].fieldContracts.movement_state, { required: true, enum: ["moving", "stopped"], }); -assert.equal(bundled[4].version, "1.0.0"); -assert.equal(bundled[4].ontologyRevision, "ontology.map.zone.v1"); -assert.deepEqual(bundled[4].semanticTypes, ["map.zone"]); -assert.deepEqual(bundled[4].fieldContracts.geometry, { type: "geometry", required: true }); -assert.equal(bundled[4].fields.includes("dataset_authority"), false); -assert.equal(bundled[5].version, "2.0.0"); +assert.equal(bundled[4].version, "5.0.0"); +assert.equal(bundled[4].ontologyRevision, "ontology.map.moving_object.v3"); +assert.equal(bundled[4].fieldContracts.sensor_readings.type, "telemetry_readings"); +assert.equal(bundled[5].version, "1.0.0"); assert.equal(bundled[5].ontologyRevision, "ontology.map.zone.v1"); assert.deepEqual(bundled[5].semanticTypes, ["map.zone"]); assert.deepEqual(bundled[5].fieldContracts.geometry, { type: "geometry", required: true }); -assert.deepEqual(bundled[5].fieldContracts.dataset_authority.enum, ["moscow-department-of-transport"]); +assert.equal(bundled[5].fields.includes("dataset_authority"), false); +assert.equal(bundled[6].version, "2.0.0"); +assert.equal(bundled[6].ontologyRevision, "ontology.map.zone.v1"); +assert.deepEqual(bundled[6].semanticTypes, ["map.zone"]); +assert.deepEqual(bundled[6].fieldContracts.geometry, { type: "geometry", required: true }); +assert.deepEqual(bundled[6].fieldContracts.dataset_authority.enum, ["moscow-department-of-transport"]); assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length); assert.equal(Object.keys(bundled[4].fieldContracts).length, bundled[4].fields.length); assert.equal(Object.keys(bundled[5].fieldContracts).length, bundled[5].fields.length); +assert.equal(Object.keys(bundled[6].fieldContracts).length, bundled[6].fields.length); const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-")); try {