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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/core/src/domain/vqt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,22 @@ export interface CurrentValueResult {
export interface HistoricalValueResult {
readonly isComposition: boolean;
readonly values: readonly VQT[];
/**
* Per-component series. A component whose history could not be read has no values
* and a `responseDetail` saying why (e.g. 501 not supported), so that it is not
* mistaken for an empty time range.
*/
readonly components?: Readonly<
Record<string, { readonly values: readonly VQT[] }>
Record<
string,
{
readonly values: readonly VQT[];
readonly responseDetail?: {
readonly title: string;
readonly status: number;
readonly detail: string;
};
}
>
> | null;
}
22 changes: 18 additions & 4 deletions packages/core/src/helpers/opcua-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,32 @@ export function cleanOpcuaVariant(variant: any): unknown {
return cleanOpcuaValue(variant);
}

/**
* i3X quality of an OPC UA StatusCode, from its severity (top two bits): Good
* (00, including subcodes like GoodClamped), Uncertain (01, e.g.
* UncertainInitialValue: a node that never received a value) or Bad (10/11).
* A missing status code is Bad, as before.
*/
export function qualityOfStatusCode(
statusCode: number | undefined,
): 'Good' | 'Uncertain' | 'Bad' {
if (statusCode === undefined) return 'Bad';
const severity = statusCode >>> 30;
if (severity === 0) return 'Good';
if (severity === 1) return 'Uncertain';
return 'Bad';
}

/** Convert a DataValue-like object to a SourceDataValue. */
export function dataValueToSource(dv: {
statusCode?: { value: number } | null;
value?: { value: unknown } | null;
sourceTimestamp?: Date | null;
serverTimestamp?: Date | null;
}): SourceDataValue {
const isGood = dv.statusCode ? dv.statusCode.value === 0 : false;
return {
value: cleanOpcuaVariant(dv.value),
quality: isGood ? 'Good' : 'Bad',
quality: qualityOfStatusCode(dv.statusCode?.value),
timestamp:
dv.sourceTimestamp?.toISOString() ??
dv.serverTimestamp?.toISOString() ??
Expand All @@ -184,10 +199,9 @@ export function dataValueToHistorical(dv: {
sourceTimestamp?: Date | null;
serverTimestamp?: Date | null;
}): SourceHistoricalValue {
const isGood = dv.statusCode ? dv.statusCode.value === 0 : false;
return {
value: cleanOpcuaVariant(dv.value),
quality: isGood ? 'Good' : 'Bad',
quality: qualityOfStatusCode(dv.statusCode?.value),
timestamp:
dv.sourceTimestamp?.toISOString() ??
dv.serverTimestamp?.toISOString() ??
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/services/history-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import type { ILogger } from '../ports/logger.js';
import type { BulkResultItem } from '../types/api.js';
import type { ModelService } from './model-service.js';

type ComponentHistory = {
values: VQT[];
responseDetail?: { title: string; status: number; detail: string };
};

const HISTORY_NOT_SUPPORTED = {
title: 'Not Implemented',
status: 501,
detail: 'History read not supported',
} as const;

export class HistoryService {
constructor(
private readonly dataSource: IDataSourcePort,
Expand Down Expand Up @@ -91,11 +102,7 @@ export class HistoryService {
return {
success: false,
elementId,
responseDetail: {
title: 'Not Implemented',
status: 501,
detail: 'History read not supported',
},
responseDetail: HISTORY_NOT_SUPPORTED,
};
}
},
Expand All @@ -112,7 +119,7 @@ export class HistoryService {
endTime: Date,
maxDepth: number,
currentDepth: number,
): Promise<Map<string, { values: VQT[] }>> {
): Promise<Map<string, ComponentHistory>> {
if (maxDepth > 0 && currentDepth >= maxDepth) return new Map();

const childIds = model.childrenById.get(parent.id) ?? [];
Expand All @@ -131,7 +138,7 @@ export class HistoryService {
}
}

const result = new Map<string, { values: VQT[] }>();
const result = new Map<string, ComponentHistory>();

// Read history for direct property children
if (propSources.length > 0) {
Expand All @@ -155,7 +162,7 @@ export class HistoryService {
this._logger.debug(
`readHistory failed for child property ${pid} (${sourceNodeId}): ${(err as Error).message}`,
);
result.set(pid, { values: [] });
result.set(pid, { values: [], responseDetail: HISTORY_NOT_SUPPORTED });
}
}),
);
Expand Down
23 changes: 23 additions & 0 deletions packages/core/tests/history-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,27 @@ describe('HistoryService', () => {
expect(results2[0]!.result!.components![tempId]!.values).toHaveLength(2);
expect(results2[0]!.result!.components![tempId]!.values[0]!.value).toBe(20.5);
});

it('marks a component whose history read failed instead of an empty series', async () => {
const ds = mockDataSource(mockNodes, mockHistory);
const modelService = new ModelService(ds, nullLogger);
const historyService = new HistoryService(ds, modelService, nullLogger);

const model = await modelService.getOrBuildModel();
const rootId = stableI3xId(rootNsu, 'instance');
const byName = (name: string) =>
[...model.nodesById.values()].find((n) => n.name === name)!.id;

const [root] = await historyService.readHistory([rootId], null, null, 2);
const components = root!.result!.components!;
expect(components[byName('ErrorNode')]).toEqual({
values: [],
responseDetail: {
title: 'Not Implemented',
status: 501,
detail: 'History read not supported',
},
});
expect(components[byName('Temperature')]!.responseDetail).toBeUndefined();
});
});
26 changes: 25 additions & 1 deletion packages/core/tests/opcua-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
dataValueToSource,
NODE_CLASS_NAMES,
qualifiedNameToNsu,
qualityOfStatusCode,
} from '../src/helpers/opcua-mapping.js';

describe('qualifiedNameToNsu', () => {
Expand Down Expand Up @@ -140,7 +141,7 @@ describe('dataValueToHistorical', () => {

it('marks bad historical value', () => {
const result = dataValueToHistorical({
statusCode: { value: 1 },
statusCode: { value: 0x80000000 },
value: { value: null },
sourceTimestamp: null,
serverTimestamp: null,
Expand Down Expand Up @@ -238,3 +239,26 @@ describe('cleanOpcuaValue', () => {
expect(cleanOpcuaValue([1, 'two', { x: 1 }])).toEqual([1, 'two', { x: 1 }]);
});
});

describe('qualityOfStatusCode (OPC UA severity)', () => {
it.each([
['Good', 0x00000000, 'Good'],
['GoodClamped, a Good subcode', 0x00300000, 'Good'],
['UncertainInitialValue, never received a value', 0x40920000, 'Uncertain'],
['UncertainLastUsableValue', 0x408f0000, 'Uncertain'],
['BadNodeIdUnknown', 0x80340000, 'Bad'],
['Bad', 0x80000000, 'Bad'],
])('maps %s to %s', (_name, statusCode, quality) => {
expect(qualityOfStatusCode(statusCode)).toBe(quality);
expect(dataValueToSource({ statusCode: { value: statusCode } }).quality).toBe(
quality,
);
expect(dataValueToHistorical({ statusCode: { value: statusCode } }).quality).toBe(
quality,
);
});

it('keeps a missing status code Bad', () => {
expect(qualityOfStatusCode(undefined)).toBe('Bad');
});
});
35 changes: 34 additions & 1 deletion packages/mcp/src/bindings/rest-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
ServerInfo,
SuccessResponse,
} from '@node-i3x/core';
import { rankObjectSuggestions } from '../utils/similarity.js';
import { MAX_FUZZY_QUERY_LENGTH, rankObjectSuggestions } from '../utils/similarity.js';
import type { ElementResult, I3XClientBinding, Suggestion } from './types.js';

/** How long the full object list used for suggestions is reused. */
Expand All @@ -22,6 +22,7 @@ export class RestClientBinding implements I3XClientBinding {
private readonly headers: Record<string, string>;
private readonly requestTimeoutMs: number;
private readonly fetchImpl?: typeof fetch;
private readonly token?: string;
private objectCache?: { at: number; objects: Promise<ObjectInstanceResponse[]> };

/**
Expand All @@ -43,6 +44,7 @@ export class RestClientBinding implements I3XClientBinding {
this.baseUrl = cleanUrl;
this.requestTimeoutMs = timeoutMs;
this.fetchImpl = fetchImpl;
this.token = token;
this.headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
Expand All @@ -52,6 +54,26 @@ export class RestClientBinding implements I3XClientBinding {
}
}

/** Normalized base URL, without a trailing /v1. */
getBaseUrl(): string {
return this.baseUrl;
}

get hasToken(): boolean {
return Boolean(this.token);
}

/**
* A binding to `baseUrl`. Without `token`, the current token is kept when the URL
* is this binding's own (re-connecting must not silently drop the configured token).
*/
connectTo(baseUrl: string, token?: string): RestClientBinding {
// No fetchImpl: a connect targets the network, even from an in-process binding.
const next = new RestClientBinding(baseUrl, token, this.requestTimeoutMs);
if (token !== undefined || next.baseUrl !== this.baseUrl || !this.token) return next;
return new RestClientBinding(baseUrl, this.token, this.requestTimeoutMs);
}

private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${path}`;
const signal = options.signal || AbortSignal.timeout(this.requestTimeoutMs);
Expand Down Expand Up @@ -199,6 +221,16 @@ export class RestClientBinding implements I3XClientBinding {
);
}

async getRelatedObjectsBulk(
elementIds: string[],
): Promise<ElementResult<RelatedObjectResult[]>[]> {
return this.postBulkPerElement<RelatedObjectResult[]>(
'/v1/objects/related',
elementIds,
{},
);
}

async getObjectValues(
elementIds: string[],
maxDepth = 1,
Expand All @@ -223,6 +255,7 @@ export class RestClientBinding implements I3XClientBinding {

/** Closest objects by elementId or displayName; a failure to list objects propagates. */
async findSuggestions(query: string): Promise<Suggestion[]> {
if (query.trim().length > MAX_FUZZY_QUERY_LENGTH) return [];
const now = Date.now();
if (!this.objectCache || now - this.objectCache.at > OBJECT_CACHE_TTL_MS) {
const objects = this.getObjects();
Expand Down
10 changes: 10 additions & 0 deletions packages/mcp/src/bindings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface HierarchyNode {
displayName?: string;
typeElementId?: string;
children?: HierarchyNode[];
/** Children could not be read; the rest of the tree is unaffected. */
error?: string;
/** Already expanded elsewhere in this tree (shared component or cycle). */
repeated?: true;
/** Children not fetched because the node budget was reached. */
truncated?: true;
}

export interface TelemetrySummary {
Expand Down Expand Up @@ -72,6 +78,10 @@ export interface I3XClientBinding {
elementId: string,
relationshipType?: string,
): Promise<RelatedObjectResult[]>;
/** Related objects of several elements in one request, one entry per elementId. */
getRelatedObjectsBulk(
elementIds: string[],
): Promise<ElementResult<RelatedObjectResult[]>[]>;
/** One entry per requested elementId, in request order. */
getObjectValues(
elementIds: string[],
Expand Down
Loading
Loading