Skip to content
Draft
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
12 changes: 12 additions & 0 deletions acs-i3x/bin/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { WebAPI } from "@amrc-factoryplus/service-api";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { routes } from "../lib/routes.js";
import { ObjectTree } from "../lib/object-tree.js";
import { DatasetStore } from "../lib/datasets.js";
import { ValueCache } from "../lib/value-cache.js";
import { History } from "../lib/history.js";
import { SubscriptionManager } from "../lib/subscriptions.js";
Expand Down Expand Up @@ -52,6 +53,16 @@ const subscriptions = new SubscriptionManager({
ttl: parseInt(env.I3X_SUBSCRIPTION_TTL || "300000"),
});

// Data Access datasets served as i3X objects. The external URL is the
// public base consumers can reach; without it descriptors carry no
// content block (the dataset graph still resolves).
const datasets = await new DatasetStore({
fplus,
namespaceUri: env.I3X_NAMESPACE_URI || "https://example.com",
dataAccessUrl: env.I3X_DATA_ACCESS_EXTERNAL_URL,
resolve: id => objectTree.getObject(id),
}).init();

// Build RAG engine (graph + search index)
const i3xRag = new I3xRag(objectTree, valueCache, history);
i3xRag.init();
Expand Down Expand Up @@ -81,6 +92,7 @@ const api = await new WebAPI({
valueCache,
history,
subscriptions,
datasets,
mcpServer,
maxDepthCap: parseInt(env.I3X_MAX_DEPTH_CAP || "0"),
}),
Expand Down
25 changes: 25 additions & 0 deletions acs-i3x/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,31 @@ Pure translation functions between Factory+ and i3X data shapes:
- `wrapError(message)` → `{ success: false, error: { message } }`
- `wrapBulkResponse(results)` → ordered bulk response with per-item success/error

### datasets.ts

Serves Data Access datasets as i3X objects (FPLUS-44). Datasets live in
the ConfigDB (Dataset class membership plus one structure App entry per
type: SparkplugSrc, SessionLimits or UnionComponents), so the store
learns about them from three list calls and a class-members read,
re-polled on a timer. Each dataset appears under a synthetic "Datasets"
folder with a shared Dataset ObjectType and a real schema.

A dataset's `/value` is a descriptor, not data: the dataset type, its
structure (source device, coverage window, or union members), and a
DCAT-shaped `content` block with the Data Access export href
(`I3X_DATA_ACCESS_EXTERNAL_URL`; omitted when unset). Structure configs
are read per request so a growing union is always current.

`/related` with `HasComponent` walks the graph: union → members,
session window → source dataset, device stream → the device object
(resolved from the ObjectTree). `/history` on a dataset answers 404
pointing at the content href — the i3X history shape cannot express a
multi-metric dataset (TID L5).

Deliberately separate from ObjectTree: the device pipeline (reactive
refresh, UNS preservation, diffing) is untouched; APIv1 consults the
store as a fallback on every object route.

### quality.ts

Derives i3X quality from device/metric state:
Expand Down
1 change: 1 addition & 0 deletions acs-i3x/docs/to-improve.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ Tracked deviations from the i3X spec and known areas for improvement.
| L2 | ObjectTypes without schemas | Return empty `{"type": "object"}` schema for ConfigDB classes missing a ConfigSchema app entry | Either require schemas or synthesise from Sparkplug birth certificate structure | Most real devices will have schemas; this is an edge case | Open |
| L3 | SSE streaming scope | SSE subscriptions only stream values from devices publishing to UNS (requires UNS ingester + ISA-95 config) | Stream from all devices, including those only on Sparkplug | Could subscribe to `spBv1.0/#` directly, or use the historian's Sparkplug data. Needs design work — Sparkplug uses protobuf + aliases which requires birth certificate tracking | Open |
| L4 | Device identity | Device elementId uses ConfigDB UUID, sub-objects use Instance_UUID. Ideally these would be consistent | Use Instance_UUID throughout, or provide a mapping endpoint | Too late to change for PoC without breaking the parent chain from ConfigDB | Open |
| L5 | Multi-metric history | The i3X history shape is one metric per object (`{ elementId, values: VQT[] }`). Compositions return an empty array (`lib/history.ts`), and a Data Access dataset's contents cannot be served through `/history` at all — dataset objects answer 404 there and point at the content href in their `/value` descriptor instead | A multi-series or tabular history shape in the spec | This is a spec limitation, not just an implementation gap. The dataset descriptor (`lib/datasets.ts`) is the evidence: a typed, multi-metric, time-bounded collection the history format cannot express. Candidate spec proposal (FPLUS-45 tracks the deliberate spec-breaking experiment) | Open |
94 changes: 78 additions & 16 deletions acs-i3x/lib/api-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import type { ObjectTree } from "./object-tree.js";
import type { ValueCache } from "./value-cache.js";
import type { History } from "./history.js";
import type { SubscriptionManager } from "./subscriptions.js";
import type { DatasetStore } from "./datasets.js";
import validator from 'validator';

interface APIv1Opts {
objectTree: ObjectTree;
valueCache: ValueCache;
history: History;
subscriptions: SubscriptionManager;
/** Data Access datasets served as i3X objects (optional). */
datasets?: DatasetStore;
/**
* Server-imposed cap on composition `maxDepth`. 0 means no cap
* (default). When set, requests for a deeper traversal are
Expand Down Expand Up @@ -67,6 +70,7 @@ export class APIv1 {
private valueCache: ValueCache;
private history: History;
private subscriptions: SubscriptionManager;
private datasets: DatasetStore | null;
private maxDepthCap: number;

/**
Expand All @@ -77,6 +81,7 @@ export class APIv1 {
this.valueCache = opts.valueCache;
this.history = opts.history;
this.subscriptions = opts.subscriptions;
this.datasets = opts.datasets ?? null;
this.maxDepthCap = opts.maxDepthCap ?? 0;

this.routes = Router();
Expand Down Expand Up @@ -135,11 +140,11 @@ export class APIv1 {
api.post("/objects/list", this.list_objects.bind(this));
api.post("/objects/value", asyncHandler(this.value_objects.bind(this)));
api.post("/objects/history", asyncHandler(this.history_objects.bind(this)));
api.post("/objects/related", this.related_objects.bind(this));
api.post("/objects/related", asyncHandler(this.related_objects.bind(this)));

api.get("/objects/:elementId/value", asyncHandler(this.get_object_value.bind(this)));
api.get("/objects/:elementId/history", asyncHandler(this.get_object_history.bind(this)));
api.get("/objects/:elementId/related", this.get_object_related.bind(this));
api.get("/objects/:elementId/related", asyncHandler(this.get_object_related.bind(this)));
api.get("/objects/:elementId", this.get_object.bind(this));

/* ---- Subscriptions ---- */
Expand Down Expand Up @@ -199,14 +204,33 @@ export class APIv1 {
**/
get_object_types(req: Request, res: Response): void {
const ns = req.query.namespaceUri as string | undefined;
res.json(this.objectTree.getObjectTypes(ns));
const types = this.objectTree.getObjectTypes(ns);
if (this.datasets) {
const dt = this.datasets.getObjectType();
if (ns === undefined || dt.namespaceUri === ns) types.push(dt);
}
res.json(types);
}

/** ObjectType lookup across the device tree and the dataset store. */
private lookupObjectType(elementId: string) {
const t = this.objectTree.getObjectType(elementId);
if (t) return t;
const dt = this.datasets?.getObjectType();
return dt?.elementId === elementId ? dt : undefined;
}

/** Object lookup across the device tree and the dataset store. */
private lookupObject(elementId: string) {
return this.objectTree.getObject(elementId)
?? this.datasets?.getObject(elementId);
}

/**
* GET /objecttypes/:elementId — returns one object type, or 404 if unknown.
**/
get_object_type(req: Request, res: Response, next: NextFunction): void {
const result = this.objectTree.getObjectType(req.params.elementId);
const result = this.lookupObjectType(req.params.elementId);
if (!result) return next(notFound(`Object type ${req.params.elementId} not found`));
res.json(result);
}
Expand All @@ -221,7 +245,7 @@ export class APIv1 {
query_object_types(req: Request, res: Response): void {
const { elementIds } = req.body;
const results = (elementIds as string[]).map(id => {
const item = this.objectTree.getObjectType(id);
const item = this.lookupObjectType(id);
if (item) {
return { success: true, elementId: id, result: item };
}
Expand Down Expand Up @@ -270,11 +294,16 @@ export class APIv1 {
* GET /objects — lists objects with optional `typeElementId`, `root`, and `includeMetadata` filters.
**/
get_objects(req: Request, res: Response): void {
res.json(this.objectTree.getObjects({
const filter = {
typeElementId: req.query.typeElementId as string | undefined,
root: req.query.root === "true",
};
const objects = this.objectTree.getObjects({
...filter,
includeMetadata: req.query.includeMetadata === "true",
}));
});
if (this.datasets) objects.push(...this.datasets.getObjects(filter));
res.json(objects);
}

/**
Expand All @@ -285,7 +314,7 @@ export class APIv1 {
list_objects(req: Request, res: Response): void {
const { elementIds, includeMetadata } = req.body;
const results = (elementIds as string[]).map(id => {
const item = this.objectTree.getObject(id);
const item = this.lookupObject(id);
if (item) {
return { success: true, elementId: id, result: item };
}
Expand All @@ -308,6 +337,12 @@ export class APIv1 {
const { elementIds, maxDepth } = req.body;
const { effective, clamped } = this.clampDepth(maxDepth ?? 1);
const results = await Promise.all((elementIds as string[]).map(async (id) => {
// Datasets: the value is a descriptor built from the ConfigDB
if (this.datasets?.has(id)) {
const item = await this.datasets.getValue(id);
if (item) return { success: true, elementId: id, result: item };
return { success: false, elementId: id, error: { code: 404, message: `No value for ${id}` } };
}
// Try UNS cache first (real-time), fall back to InfluxDB last()
const cached = this.valueCache.getValue(id);
if (cached) {
Expand Down Expand Up @@ -348,6 +383,12 @@ export class APIv1 {
const results = await Promise.all(
(elementIds as string[]).map(async id => {
try {
if (this.datasets?.has(id)) {
throw Object.assign(new Error(
"Dataset contents are not served over history; "
+ "fetch the content href from the dataset's value"),
{ status: 404 });
}
const values = await this.history.queryHistory(id, startTime, endTime, maxDepth);
return { success: true, elementId: id, result: { elementId: id, values } };
} catch (err: any) {
Expand All @@ -364,16 +405,20 @@ export class APIv1 {
* filtered by `relationshiptype`. Per-id success/error envelope:
* missing ids are reported as failures.
*/
related_objects(req: Request, res: Response): void {
async related_objects(req: Request, res: Response): Promise<void> {
const { elementIds, relationshiptype } = req.body;
const results = (elementIds as string[]).map(id => {
const results = await Promise.all((elementIds as string[]).map(async id => {
if (this.datasets?.has(id)) {
const related = await this.datasets.getRelated(id, relationshiptype);
return { success: true, elementId: id, result: related ?? [] };
}
const obj = this.objectTree.getObject(id);
if (!obj) {
return { success: false, elementId: id, error: { code: 404, message: `Object ${id} not found` } };
}
const related = this.objectTree.getRelated(id, relationshiptype);
return { success: true, elementId: id, result: related };
});
}));
const allSuccess = results.every(r => r.success);
((res as any)._originalJson || res.json.bind(res))({ success: allSuccess, results });
}
Expand All @@ -386,6 +431,13 @@ export class APIv1 {
*/
async get_object_value(req: Request, res: Response, next: NextFunction): Promise<void> {
const id = req.params.elementId;
// Datasets: the value is a descriptor built from the ConfigDB
if (this.datasets?.has(id)) {
const result = await this.datasets.getValue(id);
if (!result) return next(notFound(`No value for ${id}`));
res.json(result);
return;
}
const obj = this.objectTree.getObject(id);
// Try UNS cache first (real-time), fall back to InfluxDB last()
const cached = this.valueCache.getValue(id);
Expand Down Expand Up @@ -415,25 +467,35 @@ export class APIv1 {
if (!startTime || !endTime) {
return next(badRequest("startTime and endTime query parameters are required"));
}
if (this.datasets?.has(req.params.elementId)) {
return next(notFound(
"Dataset contents are not served over history; "
+ "fetch the content href from the dataset's value"));
}
const values = await this.history.queryHistory(req.params.elementId, startTime, endTime);
res.json({ elementId: req.params.elementId, values });
}

/**
* GET /objects/:elementId/related — related objects, optionally filtered by `relationshiptype`. 404 if the source object is unknown.
**/
get_object_related(req: Request, res: Response, next: NextFunction): void {
const obj = this.objectTree.getObject(req.params.elementId);
if (!obj) return next(notFound(`Object ${req.params.elementId} not found`));
async get_object_related(req: Request, res: Response, next: NextFunction): Promise<void> {
const id = req.params.elementId;
const rt = req.query.relationshiptype as string | undefined;
res.json(this.objectTree.getRelated(req.params.elementId, rt));
if (this.datasets?.has(id)) {
res.json(await this.datasets.getRelated(id, rt) ?? []);
return;
}
const obj = this.objectTree.getObject(id);
if (!obj) return next(notFound(`Object ${id} not found`));
res.json(this.objectTree.getRelated(id, rt));
}

/**
* GET /objects/:elementId — returns one object, or 404 if unknown.
**/
get_object(req: Request, res: Response, next: NextFunction): void {
const result = this.objectTree.getObject(req.params.elementId);
const result = this.lookupObject(req.params.elementId);
if (!result) return next(notFound(`Object ${req.params.elementId} not found`));
res.json(result);
}
Expand Down
12 changes: 12 additions & 0 deletions acs-i3x/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ export const DEVICE_CLASS_UUID = "18773d6d-a70d-443a-b29a-3f158319529
export const SCHEMA_APP_UUID = "b16e85fb-53c2-49f9-8d83-cdf6763304ba";
export const INFO_APP_UUID = "64a8bfa9-7772-45c4-9d1a-9e6290690957";
export const DEVICE_INFORMATION_APP_UUID = "a98ffed5-c613-4e70-bfd3-efeee250ade5";

// Namespace for synthetic v5 UUIDs (metric path segments without an
// Instance_UUID, and the dataset folder / object type).
export const I3X_UUID_NAMESPACE = "11ad7b32-1d32-4c4a-b0c9-fa049208939a";

// Data Access dataset representation in the ConfigDB (see
// acs-data-access/lib/constants.js). Membership of the Dataset class
// plus one structure App entry per dataset type.
export const DATASET_CLASS_UUID = "c31d3cbd-01cd-4833-8014-c4512aef1e5c";
export const DATASET_APP_SPARKPLUG_SRC = "f5d550c4-2831-11f1-b0b0-83fda3035799";
export const DATASET_APP_SESSION_LIMITS = "8754c000-3778-4ae6-b2b8-bbcd959bb775";
export const DATASET_APP_UNION_COMPONENTS = "1c4ca454-de38-44d9-92fb-aa5218bfa257";
Loading
Loading