beta. OPC fixed + tested

This commit is contained in:
jhartworks
2026-06-29 15:24:26 +02:00
parent a12ef41d1b
commit 2ce3be068e
9 changed files with 1614 additions and 399 deletions
+301 -58
View File
@@ -116,21 +116,20 @@ function createRandomPointColor(seed) {
export function normalizeEngineeringUnit(value) {
const raw = String(value ?? "").trim();
if (!raw) {
return "°C";
return "\u00B0C";
}
const normalized = raw
.replace(/°/g, "°")
.replace(/^\?C$/i, "°C")
.replace(/^°\s*C$/i, "°C");
if (/^\?C$/i.test(raw) || /^\u00B0\s*C$/i.test(raw) || (/[^\x00-\x7F]/.test(raw) && /C/i.test(raw))) {
return "\u00B0C";
}
return normalized || "°C";
return raw;
}
function createDefaultPoint(index) {
return {
alias: `Wert${index}`,
unit: "°C",
unit: "\u00B0C",
factor: 1,
kind: "analog",
min: 0,
@@ -164,7 +163,7 @@ async function ensureWideTrendColumns(pool, tableName) {
}
}
export async function createWideTrendTable(pool, tableName) {
export async function createLegacyWideTrendTable(pool, tableName) {
const safeTable = sanitizeIspName(tableName);
const sql = `
CREATE TABLE IF NOT EXISTS \`${safeTable}\` (
@@ -181,18 +180,145 @@ export async function createWideTrendTable(pool, tableName) {
return safeTable;
}
export async function createTrendTable(pool, tableName) {
const safeTable = sanitizeIspName(tableName);
await pool.query(`
CREATE TABLE IF NOT EXISTS \`${safeTable}\` (
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`userlevel\` INT(1) NOT NULL DEFAULT 0,
\`source_id\` VARCHAR(191) NOT NULL,
\`point_index\` INT NOT NULL,
\`alias\` VARCHAR(255) NOT NULL,
\`value\` TEXT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_${safeTable}_datum\` (\`datum\`),
KEY \`idx_${safeTable}_point_datum\` (\`point_index\`, \`datum\`),
KEY \`idx_${safeTable}_source_point\` (\`source_id\`, \`point_index\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
return safeTable;
}
async function loadSourcePointSeeds(pool, tableName) {
const safeTable = sanitizeIspName(tableName);
try {
const [rows] = await pool.query(
`
SELECT
point_index AS pointIndex,
alias,
name,
unit,
kind
FROM \`source_datapoint_registry\`
WHERE table_name = ?
ORDER BY point_index ASC, updated_at ASC
`,
[safeTable]
);
return rows.map((row) => ({
pointIndex: sanitizePointIndex(row.pointIndex),
alias: String(row.alias || "").trim(),
name: String(row.name || "").trim(),
unit: String(row.unit || "").trim(),
kind: row.kind === "digital" ? "digital" : "analog",
}));
} catch (error) {
if (String(error?.message || "").includes("doesn't exist")) {
return [];
}
throw error;
}
}
async function hasSourceRegistryEntry(pool, tableName) {
const safeTable = sanitizeIspName(tableName);
try {
const [rows] = await pool.query(
"SELECT 1 FROM `source_registry` WHERE table_name = ? LIMIT 1",
[safeTable]
);
return rows.length > 0;
} catch (error) {
if (String(error?.message || "").includes("doesn't exist")) {
return false;
}
throw error;
}
}
async function describeTrendTable(pool, dbName, tableName) {
const safeTable = sanitizeIspName(tableName);
const usesExplicitSchema = Boolean(dbName);
const [columns] = await pool.query(
`
SELECT COLUMN_NAME AS columnName
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ${usesExplicitSchema ? "?" : "DATABASE()"}
AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
`,
usesExplicitSchema ? [dbName, safeTable] : [safeTable]
);
const columnNames = columns.map((column) => column.columnName);
const lowerNames = new Set(columnNames.map((name) => String(name).toLowerCase()));
const widePointCount = columnNames.filter((name) => /^Wert\d+$/i.test(name)).length;
if (widePointCount > 0) {
const [rows] = await pool.query(`SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\``);
return {
tableName: safeTable,
storage: "wide",
pointCount: widePointCount,
rowCount: Number(rows[0]?.rowCount || 0),
latestTimestamp: rows[0]?.latestTimestamp || null,
};
}
if (lowerNames.has("datum") && lowerNames.has("point_index") && lowerNames.has("value")) {
const sourceSeeds = await loadSourcePointSeeds(pool, safeTable);
const sourceBacked = await hasSourceRegistryEntry(pool, safeTable);
const [rows] = await pool.query(
`
SELECT
COUNT(*) AS rowCount,
MAX(datum) AS latestTimestamp,
COALESCE(MAX(point_index), 0) AS pointCount
FROM \`${safeTable}\`
`
);
const measuredPointCount = Number(rows[0]?.pointCount || 0);
const pointCount = sourceSeeds.length ? sourceSeeds.length : measuredPointCount;
return {
tableName: safeTable,
storage: "narrow",
pointCount: pointCount || (sourceBacked ? 0 : (safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT)),
rowCount: Number(rows[0]?.rowCount || 0),
latestTimestamp: rows[0]?.latestTimestamp || null,
};
}
return null;
}
export async function resolveTrendTablePointCount(pool, dbName, tableName) {
const safeTable = sanitizeIspName(tableName);
const info = await describeTrendTable(pool, dbName, safeTable);
if (!info) {
return safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT;
}
return Number(info.pointCount || 0) || (safeTable.startsWith("trend_") ? LOCAL_POINT_LIMIT : LEGACY_POINT_LIMIT);
}
export async function getAvailableIsps(pool, dbName) {
const [tables] = await pool.query(
`
SELECT
t.TABLE_NAME AS tableName,
(
SELECT COUNT(*)
FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA
AND c.TABLE_NAME = t.TABLE_NAME
AND c.COLUMN_NAME LIKE 'Wert%'
) AS pointCount
SELECT t.TABLE_NAME AS tableName
FROM information_schema.TABLES t
WHERE t.TABLE_SCHEMA = ?
AND (t.TABLE_NAME LIKE 'isp%' OR t.TABLE_NAME LIKE 'trend_%')
@@ -201,36 +327,17 @@ export async function getAvailableIsps(pool, dbName) {
[dbName]
);
const stats = new Map();
await Promise.all(
tables.map(async (table) => {
const safeTable = sanitizeIspName(table.tableName);
const [rows] = await pool.query(
`SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\``
);
stats.set(safeTable, rows[0] || { rowCount: 0, latestTimestamp: null });
})
);
return tables.map((table) => {
const safeTable = sanitizeIspName(table.tableName);
const info = stats.get(safeTable) || {};
return {
tableName: safeTable,
pointCount: Number(table.pointCount || 0),
rowCount: Number(info.rowCount || 0),
latestTimestamp: info.latestTimestamp || null,
};
});
const described = await Promise.all(tables.map((table) => describeTrendTable(pool, dbName, table.tableName)));
return described.filter(Boolean);
}
export function createDefaultMetadata(isp, pointCount = LEGACY_POINT_LIMIT) {
const points = {};
const pointOrder = [];
for (let index = 1; index <= pointCount; index += 1) {
points[String(index)] = createDefaultPoint(index);
const key = String(index);
points[key] = createDefaultPoint(index);
pointOrder.push(key);
}
return {
@@ -238,12 +345,18 @@ export function createDefaultMetadata(isp, pointCount = LEGACY_POINT_LIMIT) {
displayName: isp.toUpperCase(),
description: "",
points,
pointOrder,
};
}
export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LIMIT) {
export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_POINT_LIMIT) {
const safeIsp = sanitizeIspName(isp);
const key = `${META_PREFIX}${safeIsp}`;
const sourceSeeds = await loadSourcePointSeeds(pool, safeIsp);
const seedMap = new Map(sourceSeeds.map((seed) => [String(seed.pointIndex), seed]));
const pointOrder = sourceSeeds.length
? sourceSeeds.map((seed) => String(seed.pointIndex))
: Array.from({ length: pointCount }, (_value, index) => String(index + 1));
const existing = await redis.get(key);
if (existing) {
@@ -255,19 +368,43 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
changed = true;
}
for (let index = 1; index <= pointCount; index += 1) {
const pointKey = String(index);
if (!Array.isArray(parsed.pointOrder)) {
parsed.pointOrder = [];
changed = true;
}
if (JSON.stringify(parsed.pointOrder) !== JSON.stringify(pointOrder)) {
parsed.pointOrder = pointOrder;
changed = true;
}
pointOrder.forEach((pointKey) => {
const index = Number(pointKey);
const defaultPoint = createDefaultPoint(index);
const sourceSeed = seedMap.get(pointKey);
const initialAlias = sourceSeed?.alias || sourceSeed?.name || defaultPoint.alias;
if (!parsed.points[pointKey]) {
parsed.points[pointKey] = defaultPoint;
parsed.points[pointKey] = {
...defaultPoint,
alias: initialAlias,
unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit),
kind: sourceSeed?.kind || defaultPoint.kind,
};
changed = true;
continue;
return;
}
const point = parsed.points[pointKey];
if (!point.alias) {
point.alias = defaultPoint.alias;
if (!point.alias || point.alias === defaultPoint.alias) {
const nextAlias = initialAlias || defaultPoint.alias;
if (point.alias !== nextAlias) {
point.alias = nextAlias;
changed = true;
}
}
if (sourceSeed?.kind && !point.kind) {
point.kind = sourceSeed.kind;
changed = true;
}
const normalizedUnit = normalizeEngineeringUnit(point.unit);
@@ -275,8 +412,15 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
point.unit = normalizedUnit;
changed = true;
}
if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit)) && sourceSeed?.unit) {
const seededUnit = normalizeEngineeringUnit(sourceSeed.unit);
if (point.unit !== seededUnit) {
point.unit = seededUnit;
changed = true;
}
}
if (!point.kind) {
point.kind = defaultPoint.kind;
point.kind = sourceSeed?.kind || defaultPoint.kind;
changed = true;
}
if (!Number.isFinite(Number(point.factor))) {
@@ -300,7 +444,7 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
point.color = defaultPoint.color;
changed = true;
}
}
});
if (changed) {
await redis.set(key, JSON.stringify(parsed));
@@ -310,18 +454,31 @@ export async function ensureIspMetadata(redis, isp, pointCount = LEGACY_POINT_LI
}
const metadata = createDefaultMetadata(safeIsp, pointCount);
metadata.pointOrder = pointOrder;
pointOrder.forEach((pointKey) => {
const sourceSeed = seedMap.get(pointKey);
if (!sourceSeed) {
return;
}
metadata.points[pointKey] = {
...metadata.points[pointKey],
alias: sourceSeed.alias || sourceSeed.name || metadata.points[pointKey].alias,
unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit),
kind: sourceSeed.kind || metadata.points[pointKey].kind,
};
});
await redis.set(key, JSON.stringify(metadata));
return metadata;
}
export async function updateIspMetadata(redis, isp, updater, pointCount = LEGACY_POINT_LIMIT) {
const metadata = await ensureIspMetadata(redis, isp, pointCount);
export async function updateIspMetadata(pool, redis, isp, updater, pointCount = LEGACY_POINT_LIMIT) {
const metadata = await ensureIspMetadata(pool, redis, isp, pointCount);
const updated = updater(JSON.parse(JSON.stringify(metadata)));
await redis.set(`${META_PREFIX}${sanitizeIspName(isp)}`, JSON.stringify(updated));
return updated;
}
export async function getPointDescriptors(redis, points) {
export async function getPointDescriptors(pool, redis, points) {
const grouped = new Map();
points.forEach((point) => {
@@ -338,10 +495,11 @@ export async function getPointDescriptors(redis, points) {
const descriptors = [];
for (const [isp, pointIndexes] of grouped.entries()) {
const metadata = await ensureIspMetadata(redis, isp, LOCAL_POINT_LIMIT);
const pointCount = await resolveTrendTablePointCount(pool, null, isp);
const metadata = await ensureIspMetadata(pool, redis, isp, pointCount);
pointIndexes.forEach((pointIndex) => {
const pointMeta = metadata.points[String(pointIndex)];
const pointMeta = metadata.points[String(pointIndex)] || createDefaultPoint(pointIndex);
descriptors.push({
isp,
pointIndex,
@@ -362,7 +520,7 @@ export async function getPointDescriptors(redis, points) {
}
export async function fetchTrendSeries(pool, redis, points, range) {
const descriptors = await getPointDescriptors(redis, points);
const descriptors = await getPointDescriptors(pool, redis, points);
const grouped = descriptors.reduce((accumulator, descriptor) => {
if (!accumulator.has(descriptor.isp)) {
accumulator.set(descriptor.isp, []);
@@ -375,6 +533,43 @@ export async function fetchTrendSeries(pool, redis, points, range) {
const mergedRows = new Map();
for (const [isp, pointDescriptors] of grouped.entries()) {
const tableInfo = await describeTrendTable(pool, null, isp);
if (!tableInfo) {
continue;
}
if (tableInfo.storage === "narrow") {
const placeholders = pointDescriptors.map(() => "?").join(", ");
const limit = Math.max(pointDescriptors.length, (range.maxRows || 4000) * Math.max(1, pointDescriptors.length));
const [rows] = await pool.query(
`
SELECT datum, point_index, value
FROM \`${isp}\`
WHERE point_index IN (${placeholders})
AND datum BETWEEN ? AND ?
ORDER BY datum DESC, id DESC
LIMIT ?
`,
[...pointDescriptors.map((descriptor) => descriptor.pointIndex), range.from, range.to, limit]
);
rows.reverse().forEach((row) => {
const timestamp = new Date(row.datum).toISOString();
if (!mergedRows.has(timestamp)) {
mergedRows.set(timestamp, { timestamp });
}
const target = mergedRows.get(timestamp);
const descriptor = pointDescriptors.find((item) => item.pointIndex === Number(row.point_index));
if (!descriptor) {
return;
}
const parsed = parseTrendValue(row.value, descriptor.factor);
target[descriptor.key] = parsed.numeric;
target[`${descriptor.key}:raw`] = parsed.raw;
});
continue;
}
const columns = pointDescriptors
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
.join(", ");
@@ -410,7 +605,7 @@ export async function fetchTrendSeries(pool, redis, points, range) {
}
export async function fetchLatestValues(pool, redis, points) {
const descriptors = await getPointDescriptors(redis, points);
const descriptors = await getPointDescriptors(pool, redis, points);
const grouped = descriptors.reduce((accumulator, descriptor) => {
if (!accumulator.has(descriptor.isp)) {
accumulator.set(descriptor.isp, []);
@@ -423,6 +618,52 @@ export async function fetchLatestValues(pool, redis, points) {
const values = [];
for (const [isp, pointDescriptors] of grouped.entries()) {
const tableInfo = await describeTrendTable(pool, null, isp);
if (!tableInfo) {
pointDescriptors.forEach((descriptor) => {
values.push({
...descriptor,
timestamp: null,
raw: null,
value: null,
});
});
continue;
}
if (tableInfo.storage === "narrow") {
const placeholders = pointDescriptors.map(() => "?").join(", ");
const [rows] = await pool.query(
`
SELECT point_index, datum, value
FROM (
SELECT
point_index,
datum,
value,
ROW_NUMBER() OVER (PARTITION BY point_index ORDER BY datum DESC, id DESC) AS point_rank
FROM \`${isp}\`
WHERE point_index IN (${placeholders})
) ranked
WHERE point_rank = 1
`,
pointDescriptors.map((descriptor) => descriptor.pointIndex)
);
const latestByPoint = new Map(rows.map((row) => [Number(row.point_index), row]));
pointDescriptors.forEach((descriptor) => {
const row = latestByPoint.get(descriptor.pointIndex);
const parsed = parseTrendValue(row?.value, descriptor.factor);
values.push({
...descriptor,
timestamp: row?.datum ? new Date(row.datum).toISOString() : null,
raw: parsed.raw,
value: parsed.numeric,
});
});
continue;
}
const columns = pointDescriptors
.map((descriptor) => `\`Wert${descriptor.pointIndex}\``)
.join(", ");
@@ -459,3 +700,5 @@ export function normalizeRange(input = {}) {
}
+96
View File
@@ -1,6 +1,7 @@
const SELECTION_SET_KEY = "selection:ids";
const SELECTION_SEQ_KEY = "selection:seq";
const ROLE_SET_KEY = "role:names";
const UNIT_SET_KEY = "unit:symbols";
export const DEFAULT_ROLE_DEFINITIONS = [
{
@@ -23,10 +24,48 @@ export const DEFAULT_ROLE_DEFINITIONS = [
},
];
export const DEFAULT_UNIT_DEFINITIONS = [
{ symbol: "\u00B0C", label: "Grad Celsius", isSystem: true },
{ symbol: "%", label: "Prozent", isSystem: true },
{ symbol: "K", label: "Kelvin", isSystem: true },
{ symbol: "V", label: "Volt", isSystem: true },
{ symbol: "A", label: "Ampere", isSystem: true },
{ symbol: "Pa", label: "Pascal", isSystem: true },
];
function safeParse(jsonValue) {
return jsonValue ? JSON.parse(jsonValue) : null;
}
function normalizeUnitSymbol(value, fallback = "") {
const raw = String(value ?? "").trim();
const collapsed = raw.replace(/\s+/g, "");
if (!collapsed) {
return fallback;
}
if (/^\?C$/i.test(collapsed) || /^Â?\u00B0C$/i.test(collapsed)) {
return "\u00B0C";
}
return raw;
}
function unitStorageKey(symbol) {
return `unit:${encodeURIComponent(symbol)}`;
}
function normalizeUnitRecord(unit) {
const symbol = normalizeUnitSymbol(unit?.symbol, "\u00B0C");
return {
symbol,
label: String(unit?.label || symbol).trim() || symbol,
isSystem: Boolean(unit?.isSystem),
updatedAt: unit?.updatedAt || new Date().toISOString(),
};
}
function normalizeRoleName(value) {
const normalized = String(value || "")
.trim()
@@ -72,6 +111,22 @@ async function ensureDefaultRoles(redis) {
}
}
async function ensureDefaultUnits(redis) {
for (const definition of DEFAULT_UNIT_DEFINITIONS) {
const key = unitStorageKey(definition.symbol);
const existing = safeParse(await redis.get(key));
const payload = normalizeUnitRecord({
...definition,
...(existing || {}),
symbol: definition.symbol,
label: existing?.label || definition.label,
isSystem: true,
});
await redis.set(key, JSON.stringify(payload));
await redis.sadd(UNIT_SET_KEY, definition.symbol);
}
}
export async function listRoles(redis) {
await ensureDefaultRoles(redis);
const names = await redis.smembers(ROLE_SET_KEY);
@@ -118,6 +173,47 @@ export async function saveRole(redis, role, previousName = "") {
return merged;
}
export async function listUnits(redis) {
await ensureDefaultUnits(redis);
const symbols = await redis.smembers(UNIT_SET_KEY);
const items = await Promise.all(symbols.map((symbol) => redis.get(unitStorageKey(symbol))));
return items
.map((item) => safeParse(item))
.filter(Boolean)
.map((unit) => normalizeUnitRecord(unit))
.sort((left, right) => left.symbol.localeCompare(right.symbol, "de-DE"));
}
export async function saveUnit(redis, unit, previousSymbol = "") {
await ensureDefaultUnits(redis);
const existingSymbol = normalizeUnitSymbol(previousSymbol || unit?.symbol, "");
const payload = normalizeUnitRecord(unit);
const current = existingSymbol ? safeParse(await redis.get(unitStorageKey(existingSymbol))) : null;
if (current?.isSystem && existingSymbol !== payload.symbol) {
throw new Error("Systemeinheiten können nicht umbenannt werden.");
}
const merged = normalizeUnitRecord({
...(current || {}),
...payload,
symbol: current?.isSystem ? existingSymbol : payload.symbol,
isSystem: Boolean(current?.isSystem || payload.isSystem),
updatedAt: new Date().toISOString(),
});
if (existingSymbol && existingSymbol !== merged.symbol) {
await redis.del(unitStorageKey(existingSymbol));
await redis.srem(UNIT_SET_KEY, existingSymbol);
}
await redis.set(unitStorageKey(merged.symbol), JSON.stringify(merged));
await redis.sadd(UNIT_SET_KEY, merged.symbol);
return merged;
}
export async function listSelections(redis, user) {
const ids = await redis.smembers(SELECTION_SET_KEY);
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`)));