signalk-parquet
Version:
SignalK plugin to save marine data directly to Parquet files with regimen-based control
300 lines (274 loc) • 9.75 kB
text/typescript
import { Router, Request, Response } from 'express';
import {
AggregateMethod,
DataResult,
FromToContextRequest,
PathSpec,
} from './HistoryAPI-types';
import { ZonedDateTime } from '@js-joda/core';
import { Context, Path, Timestamp } from '@signalk/server-api';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParsedQs } from 'qs';
import { DuckDBInstance } from '@duckdb/node-api';
import { toContextFilePath } from '.';
import path from 'path';
export function registerHistoryApiRoute(
router: Pick<Router, 'get'>,
selfId: string,
dataDir: string,
debug: (k: string) => void
) {
const historyApi = new HistoryAPI(selfId, dataDir);
router.get('/signalk/v1/history/values', (req: Request, res: Response) => {
const { from, to, context } = getRequestParams(
req as FromToContextRequest,
selfId
);
historyApi.getValues(context, from, to, debug, req, res);
});
router.get('/signalk/v1/history/contexts', (req: Request, res: Response) => {
//TODO implement retrieval of contexts for the given period
res.json([`vessels.${selfId}`] as Context[]);
});
router.get('/signalk/v1/history/paths', (req: Request, res: Response) => {
//TODO implement retrieval of paths for the given period
// const { from, to } = getRequestParams(req as FromToContextRequest, selfId);
// getPaths(influx, from, to, res);
res.json(['navigation.speedOverGround']);
});
// Also register as plugin-style routes for testing
router.get('/api/history/values', (req: Request, res: Response) => {
const { from, to, context } = getRequestParams(
req as FromToContextRequest,
selfId
);
historyApi.getValues(context, from, to, debug, req, res);
});
router.get('/api/history/contexts', (req: Request, res: Response) => {
res.json([`vessels.${selfId}`] as Context[]);
});
router.get('/api/history/paths', (req: Request, res: Response) => {
res.json(['navigation.speedOverGround']);
});
}
const getRequestParams = ({ query }: FromToContextRequest, selfId: string) => {
try {
const from = ZonedDateTime.parse(query['from']);
const to = ZonedDateTime.parse(query['to']);
const context: Context = getContext(query.context, selfId);
const bbox = query['bbox'];
return { from, to, context, bbox };
} catch (e: unknown) {
throw new Error(
`Error extracting from/to query parameters from ${JSON.stringify(query)}`
);
}
};
function getContext(contextFromQuery: string, selfId: string): Context {
if (
!contextFromQuery ||
contextFromQuery === 'vessels.self' ||
contextFromQuery === 'self'
) {
return `vessels.${selfId}` as Context;
}
return contextFromQuery.replace(/ /gi, '') as Context;
}
class HistoryAPI {
readonly selfContextPath: string;
constructor(
private selfId: string,
private dataDir: string
) {
this.selfContextPath = toContextFilePath(`vessels.${selfId}` as Context);
}
async getValues(
context: Context,
from: ZonedDateTime,
to: ZonedDateTime,
debug: (k: string) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res: Response<any, Record<string, any>>
) {
try {
const timeResolutionMillis =
(req.query.resolution
? Number.parseFloat(req.query.resolution as string)
: (to.toEpochSecond() - from.toEpochSecond()) / 500) * 1000;
const pathExpressions = ((req.query.paths as string) || '')
.replace(/[^0-9a-z.,:]/gi, '')
.split(',');
const pathSpecs: PathSpec[] = pathExpressions.map(splitPathExpression);
// Handle position and numeric paths together
const allResult = pathSpecs.length
? await this.getNumericValues(
context,
from,
to,
timeResolutionMillis,
pathSpecs,
debug
)
: Promise.resolve({
context,
range: {
from: from.toString() as Timestamp,
to: to.toString() as Timestamp,
},
values: [],
data: [],
});
res.json(allResult);
} catch (error) {
debug(`Error in getValues: ${error}`);
res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : String(error),
});
}
}
async getNumericValues(
context: Context,
from: ZonedDateTime,
to: ZonedDateTime,
timeResolutionMillis: number,
pathSpecs: PathSpec[],
debug: (k: string) => void
): Promise<DataResult> {
const allData: { [path: string]: Array<[Timestamp, unknown]> } = {};
// Process each path and collect data
await Promise.all(
pathSpecs.map(async pathSpec => {
try {
// Sanitize the path to prevent directory traversal and SQL injection
const sanitizedPath = pathSpec.path
.replace(/[^a-zA-Z0-9._]/g, '') // Only allow alphanumeric, dots, underscores
.replace(/\./g, '/');
const filePath = path.join(
this.dataDir,
this.selfContextPath,
sanitizedPath,
'*.parquet'
);
// Convert ZonedDateTime to ISO string format matching parquet schema
const fromIso = from.toInstant().toString();
const toIso = to.toInstant().toString();
// Build query with sanitized inputs (filePath is already sanitized above)
const query = `
SELECT
signalk_timestamp,
value,
value_json
FROM '${filePath}'
WHERE
signalk_timestamp >= '${fromIso}'
AND
signalk_timestamp < '${toIso}'
ORDER BY signalk_timestamp
`;
debug(`Executing query for path ${pathSpec.path}: ${query}`);
const duckDB = await DuckDBInstance.create();
const connection = await duckDB.connect();
try {
const result = await connection.runAndReadAll(query);
const rows = result.getRowObjects();
// Convert rows to the expected format
const pathData: Array<[Timestamp, unknown]> = rows.map(
(row: unknown) => {
const rowData = row as {
signalk_timestamp: Timestamp;
value: unknown;
value_json?: string;
};
const timestamp = rowData.signalk_timestamp;
// Handle both JSON values (like position objects) and simple values
const value = rowData.value_json
? JSON.parse(String(rowData.value_json))
: rowData.value;
// For position paths, ensure we return the full position object
if (
pathSpec.path === 'navigation.position' &&
value &&
typeof value === 'object'
) {
// Position data is already an object with latitude/longitude
// No reassignment needed, keeping original value
}
return [timestamp, value];
}
);
allData[pathSpec.path] = pathData;
debug(
`Retrieved ${pathData.length} data points for ${pathSpec.path}`
);
} finally {
connection.disconnectSync();
}
} catch (error) {
debug(`Error querying path ${pathSpec.path}: ${error}`);
allData[pathSpec.path] = [];
}
})
);
// Merge all path data into time-ordered rows
const mergedData = this.mergePathData(allData, pathSpecs);
return {
context,
range: {
from: from.toString() as Timestamp,
to: to.toString() as Timestamp,
},
values: pathSpecs.map(({ path, aggregateMethod }: PathSpec) => ({
path,
method: aggregateMethod,
})),
data: mergedData,
} as DataResult;
}
private mergePathData(
allData: { [path: string]: Array<[Timestamp, unknown]> },
pathSpecs: PathSpec[]
): Array<[Timestamp, ...unknown[]]> {
// Create a map of all unique timestamps
const timestampMap = new Map<string, unknown[]>();
pathSpecs.forEach((pathSpec, index) => {
const pathData = allData[pathSpec.path] || [];
pathData.forEach(([timestamp, value]) => {
const timestampStr = timestamp.toString();
if (!timestampMap.has(timestampStr)) {
timestampMap.set(
timestampStr,
new Array(pathSpecs.length).fill(null)
);
}
timestampMap.get(timestampStr)![index] = value;
});
});
// Convert to sorted array format
return Array.from(timestampMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([timestamp, values]) => [timestamp as Timestamp, ...values]);
}
}
function splitPathExpression(pathExpression: string): PathSpec {
const parts = pathExpression.split(':');
let aggregateMethod = (parts[1] || 'average') as AggregateMethod;
if (parts[0] === 'navigation.position') {
aggregateMethod = 'first' as AggregateMethod;
}
return {
path: parts[0] as Path,
queryResultName: parts[0].replace(/\./g, '_'),
aggregateMethod,
aggregateFunction:
(functionForAggregate[aggregateMethod] as string) || 'mean()',
};
}
const functionForAggregate: { [key: string]: string } = {
average: 'avg',
min: 'min',
max: 'max',
first: 'first',
} as const;