feat(data-plane): add bounded Gelios telemetry contract
This commit is contained in:
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user