diff --git a/cli/src/commands/analysis.ts b/cli/src/commands/analysis.ts new file mode 100644 index 0000000..a17e4a4 --- /dev/null +++ b/cli/src/commands/analysis.ts @@ -0,0 +1,199 @@ +import { CliError } from "../core/errors.js"; +import { emitApi, requestJson } from "../core/http.js"; +import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js"; +import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js"; +import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js"; +import { parseTime } from "../core/time.js"; +import { success } from "../core/output.js"; +import type { HandlerMap, RuntimeContext } from "../core/types.js"; + +function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { duration: "integer" }); + const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file")); + const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!; + return emitApi(ctx, "爆管分析执行成功", { + method: "GET", + path: "/burst_analysis/", + params: { + network: requireNetwork(ctx), + modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + burst_ID: ids, + burst_size: sizes, + modify_total_duration: requiredNumber(values, "duration"), + scheme_name: schemeName, + }, + requireNetworkCtx: true, + }, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]); +} + +function analysisValve(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" }); + const mode = validateChoice(requiredString(values, "mode"), ["close", "isolation"] as const, "--mode"); + if (mode === "close") { + const valves = optionalStringArray(values, "valve"); + const startTime = optionalString(values, "start-time"); + if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2); + return emitApi(ctx, "阀门关闭分析执行成功", { + method: "GET", + path: "/valve_close_analysis/", + params: { + network: requireNetwork(ctx), + start_time: parseTime(startTime, "--start-time"), + valves, + duration: optionalNumber(values, "duration") || 900, + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + }, + requireNetworkCtx: true, + }); + } + const elements = optionalStringArray(values, "element"); + if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2); + return emitApi(ctx, "阀门隔离分析执行成功", { + method: "GET", + path: "/valve_isolation_analysis/", + params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") }, + requireNetworkCtx: true, + }); +} + +function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { flow: "number", duration: "integer" }); + const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file")); + return emitApi(ctx, "冲洗分析执行成功", { + method: "GET", + path: "/flushing_analysis/", + params: { + network: requireNetwork(ctx), + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + valves, + valves_k: openings, + drainage_node_ID: requiredString(values, "drainage-node"), + flush_flow: requiredNumber(values, "flow"), + duration: optionalNumber(values, "duration") || 900, + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + }, + requireNetworkCtx: true, + }); +} + +function analysisAge(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { duration: "integer" }); + return emitApi(ctx, "水龄分析执行成功", { + method: "GET", + path: "/age_analysis/", + params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") }, + requireNetworkCtx: true, + }); +} + +function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { duration: "integer", concentration: "number" }); + const params: Record = { + network: requireNetwork(ctx), + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + source: requiredString(values, "source-node"), + concentration: requiredNumber(values, "concentration"), + duration: requiredNumber(values, "duration"), + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + }; + const pattern = optionalString(values, "pattern"); + if (pattern) params.pattern = pattern; + return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant_simulation/", params, requireNetworkCtx: true }); +} + +function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" }); + return emitApi(ctx, "传感器选址执行成功", { + method: "POST", + path: "/pressure_sensor_placement_kmeans/", + body: { + name: requireNetwork(ctx), + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + sensor_number: requiredNumber(values, "count"), + min_diameter: optionalNumber(values, "min-diameter") || 0, + username: requireUsername(ctx), + }, + requireNetworkCtx: true, + requireUsernameCtx: true, + }); +} + +function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, summary, { + method: "POST", + path, + body: { + [networkKey]: requireNetwork(ctx), + [startKey]: parseTime(requiredString(values, "start-time"), "--start-time"), + [endKey]: parseTime(requiredString(values, "end-time"), "--end-time"), + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + }, + requireNetworkCtx: true, + }); +} + +function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise { + return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +} + +function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise { + const { positionals } = parseOptions(argv); + if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2); + return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +} + +function burstLocation(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" }); + const body: Record = { + network: requireNetwork(ctx), + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + data_source: optionalString(values, "data-source") || "monitoring", + scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"), + scada_burst_end: parseTime(requiredString(values, "end-time"), "--end-time"), + burst_leakage: requiredNumber(values, "burst-leakage"), + use_scada_flow: Boolean(values["use-scada-flow"]), + }; + const pressureIds = optionalStringArray(values, "pressure-scada-id"); + const flowIds = optionalStringArray(values, "flow-scada-id"); + if (pressureIds) body.pressure_scada_ids = pressureIds; + if (flowIds) body.flow_scada_ids = flowIds; + const pressureFile = optionalString(values, "pressure-file"); + const flowFile = optionalString(values, "flow-file"); + if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure"); + if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow"); + return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-location/locate/", body, requireNetworkCtx: true }); +} + +function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), pipe_id: requiredString(values, "pipe") }, requireNetworkCtx: true }); +} + +async function riskNetwork(ctx: RuntimeContext): Promise { + const network = requireNetwork(ctx); + const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true }); + const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true }); + success("读取全网风险成功", { probabilities, geometries }, ctx, a + b); +} + +export const analysisHandlers: HandlerMap = { + "analysis burst": analysisBurst, + "analysis valve": analysisValve, + "analysis flushing": analysisFlushing, + "analysis age": analysisAge, + "analysis contaminant": analysisContaminant, + "analysis sensor-placement kmeans": sensorKmeans, + "analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"), + "analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"), + "analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"), + "analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"), + "analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"), + "analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"), + "analysis burst-location locate": burstLocation, + "analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"), + "analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"), + "analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"), + "analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"), + "analysis risk network": riskNetwork, +}; diff --git a/cli/src/commands/component.ts b/cli/src/commands/component.ts new file mode 100644 index 0000000..c98c25e --- /dev/null +++ b/cli/src/commands/component.ts @@ -0,0 +1,34 @@ +import { CliError } from "../core/errors.js"; +import { emitApi } from "../core/http.js"; +import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js"; +import { requireNetwork } from "../core/runtime.js"; +import type { HandlerMap, RuntimeContext } from "../core/types.js"; + +type ComponentKind = "time" | "energy" | "pump-energy" | "network"; + +function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean): Promise { + const { values } = parseOptions(argv); + const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind"); + const routes: Record<`${ComponentKind}:${boolean}`, string> = { + "time:true": "/gettimeschema", + "time:false": "/gettimeproperties/", + "energy:true": "/getenergyschema/", + "energy:false": "/getenergyproperties/", + "pump-energy:true": "/getpumpenergyschema/", + "pump-energy:false": "/getpumpenergyproperties//", + "network:true": "/getoptionschema/", + "network:false": "/getoptionproperties/", + }; + const params: Record = { network: requireNetwork(ctx) }; + const pump = optionalString(values, "pump"); + if (kind === "pump-energy") { + if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2); + if (pump) params.pump = pump; + } + return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireNetworkCtx: true }); +} + +export const componentHandlers: HandlerMap = { + "component option schema": (ctx, argv) => componentOption(ctx, argv, true), + "component option get": (ctx, argv) => componentOption(ctx, argv, false), +}; diff --git a/cli/src/commands/data.ts b/cli/src/commands/data.ts new file mode 100644 index 0000000..ee00c51 --- /dev/null +++ b/cli/src/commands/data.ts @@ -0,0 +1,167 @@ +import { SCADA_FIELDS, type ElementType } from "../core/constants.js"; +import { CliError } from "../core/errors.js"; +import { emitApi } from "../core/http.js"; +import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js"; +import { requireNetwork, resolveScheme } from "../core/runtime.js"; +import { parseTime } from "../core/time.js"; +import type { HandlerMap, RuntimeContext } from "../core/types.js"; + +function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, summary, { + method: "GET", + path, + params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time") }, + requireProject: true, + }); +} + +function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, "读取实时模拟数据成功", { + method: "GET", + path: "/realtime/query/by-id-time", + params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") }, + requireProject: true, + }); +} + +function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"); + return emitApi(ctx, "读取实时属性聚合数据成功", { + method: "GET", + path: "/realtime/query/by-time-property", + params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") }, + requireProject: true, + }); +} + +function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, "读取方案管道数据成功", { + method: "GET", + path: "/scheme/links", + params: { + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + scheme_type: optionalString(values, "scheme-type") || "simulation", + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + end_time: parseTime(requiredString(values, "end-time"), "--end-time"), + }, + requireProject: true, + }); +} + +function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, "读取方案节点字段成功", { + method: "GET", + path: `/scheme/nodes/${requiredString(values, "node")}/field`, + params: { + field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"), + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + scheme_type: optionalString(values, "scheme-type") || "simulation", + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + end_time: parseTime(requiredString(values, "end-time"), "--end-time"), + }, + requireProject: true, + }); +} + +function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + const query = validateChoice(requiredString(values, "query"), ["by-id-time", "by-scheme-time-property"] as const, "--query"); + const type = validateChoice(optionalString(values, "type") || "pipe", ["pipe", "junction"] as const, "--type") as ElementType; + const params: Record = { + scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), + scheme_type: optionalString(values, "scheme-type") || "simulation", + query_time: parseTime(requiredString(values, "time"), "--time"), + type, + }; + if (query === "by-id-time") { + params.id = requiredString(values, "id"); + return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/scheme/query/by-id-time", params, requireProject: true }); + } + params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property"); + return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/scheme/query/by-scheme-time-property", params, requireProject: true }); +} + +function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { "device-id": "repeat" }); + const params: Record = { + device_ids: requiredStringArray(values, "device-id").join(","), + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + end_time: parseTime(requiredString(values, "end-time"), "--end-time"), + }; + const field = optionalString(values, "field"); + if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field"); + return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true }); +} + +function composite(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { feature: "repeat", "use-cleaned": "boolean" }); + const kind = validateChoice(requiredString(values, "kind"), ["scada-simulation", "element-simulation", "element-scada"] as const, "--kind"); + const params: Record = { + start_time: parseTime(requiredString(values, "start-time"), "--start-time"), + end_time: parseTime(requiredString(values, "end-time"), "--end-time"), + }; + const schemeName = resolveScheme(ctx, optionalString(values, "scheme")); + if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" }); + if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(","); + else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(","); + else { + const feature = requiredStringArray(values, "feature"); + if (feature.length !== 1) throw new CliError("CLI 参数错误", "FEATURE_REQUIRED", "element-scada requires exactly one --feature as element_id", 2); + params.element_id = feature[0]; + params.use_cleaned = Boolean(values["use-cleaned"]); + } + return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true }); +} + +function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + requiredString(values, "pipe"); + requiredString(values, "start-time"); + return emitApi(ctx, "读取管道健康预测成功", { + method: "GET", + path: "/composite/pipeline-health-prediction", + params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") }, + requireProject: true, + requireNetworkCtx: true, + }); +} + +function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); + return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true }); +} + +function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); + return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +} + +function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, "读取方案成功", { method: "GET", path: "/getscheme/", params: { network: requireNetwork(ctx), schema_name: requiredString(values, "name") }, requireNetworkCtx: true }); +} + +export const dataHandlers: HandlerMap = { + "data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"), + "data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"), + "data timeseries realtime simulation-by-id-time": realtimeByIdTime, + "data timeseries realtime simulation-by-time-property": realtimeByTimeProperty, + "data timeseries scheme links": schemeLinks, + "data timeseries scheme node-field": schemeNodeField, + "data timeseries scheme simulation": schemeSimulation, + "data timeseries scada query": scadaQuery, + "data timeseries composite": composite, + "data timeseries composite pipeline-health": pipelineHealth, + "data scada get": dataScadaGet, + "data scada list": dataScadaList, + "data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }), + "data scheme get": dataSchemeGet, + "data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/getallschemes/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }), +}; diff --git a/cli/src/commands/network.ts b/cli/src/commands/network.ts new file mode 100644 index 0000000..aaf4963 --- /dev/null +++ b/cli/src/commands/network.ts @@ -0,0 +1,27 @@ +import { emitApi } from "../core/http.js"; +import { parseOptions, requiredString } from "../core/options.js"; +import { requireNetwork } from "../core/runtime.js"; +import type { HandlerMap, RuntimeContext } from "../core/types.js"; + +function legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise { + const { values } = parseOptions(argv); + return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), [key]: requiredString(values, key) }, requireNetworkCtx: true }); +} + +function legacyGetAll(ctx: RuntimeContext, summary: string, path: string): Promise { + return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +} + +export const networkHandlers: HandlerMap = { + "network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"), + "network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"), + "network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"), + "network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"), + "network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"), + "network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"), + "network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"), + "network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"), + "network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"), + "network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"), + "network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"), +}; diff --git a/cli/src/commands/simulation.ts b/cli/src/commands/simulation.ts new file mode 100644 index 0000000..a0fd70b --- /dev/null +++ b/cli/src/commands/simulation.ts @@ -0,0 +1,26 @@ +import { emitApi } from "../core/http.js"; +import { parseOptions, requiredNumber, requiredString } from "../core/options.js"; +import { requireNetwork } from "../core/runtime.js"; +import { addMinutesPreservingOffset, parseTime } from "../core/time.js"; +import type { HandlerMap, RuntimeContext } from "../core/types.js"; + +function simulationRun(ctx: RuntimeContext, argv: string[]): Promise { + const { values } = parseOptions(argv, { duration: "integer" }); + const start = parseTime(requiredString(values, "start-time"), "--start-time"); + const duration = requiredNumber(values, "duration"); + const end = addMinutesPreservingOffset(start, duration); + const network = requireNetwork(ctx); + return emitApi( + ctx, + "触发模拟成功", + { method: "POST", path: "/runsimulationmanuallybydate/", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true }, + [ + `tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`, + `tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`, + ], + ); +} + +export const simulationHandlers: HandlerMap = { + "simulation run": simulationRun, +}; diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts new file mode 100644 index 0000000..11f6181 --- /dev/null +++ b/cli/src/core/constants.ts @@ -0,0 +1,13 @@ +export const SCHEMA_VERSION = "tjwater-cli/v1"; +export const DEFAULT_TIMEOUT = 180; +export const DEFAULT_SERVER = "http://192.168.1.114:8000"; + +export const PIPE_FIELDS = ["flow", "friction", "headloss", "quality", "reaction", "setting", "status", "velocity"] as const; +export const JUNCTION_FIELDS = ["actual_demand", "total_head", "pressure", "quality"] as const; +export const SCADA_FIELDS = ["monitored_value", "cleaned_value"] as const; + +export type ElementType = "pipe" | "junction"; +export type PipeField = (typeof PIPE_FIELDS)[number]; +export type JunctionField = (typeof JUNCTION_FIELDS)[number]; +export type ScadaField = (typeof SCADA_FIELDS)[number]; + diff --git a/cli/src/core/errors.ts b/cli/src/core/errors.ts new file mode 100644 index 0000000..a0a6c5f --- /dev/null +++ b/cli/src/core/errors.ts @@ -0,0 +1,31 @@ +export class CliError extends Error { + summary: string; + code: string; + exitCode: number; + retryable: boolean; + data: unknown; + nextCommands: string[]; + + constructor( + summary: string, + code: string, + message: string, + exitCode = 2, + retryable = false, + data: unknown = null, + nextCommands: string[] = [], + ) { + super(message); + this.summary = summary; + this.code = code; + this.exitCode = exitCode; + this.retryable = retryable; + this.data = data; + this.nextCommands = nextCommands; + } +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + diff --git a/cli/src/core/files.ts b/cli/src/core/files.ts new file mode 100644 index 0000000..4b40da3 --- /dev/null +++ b/cli/src/core/files.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { CliError } from "./errors.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function readJsonFile(path: string, label: string): unknown { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + throw new CliError("CLI 参数错误", "INPUT_NOT_FOUND", `${label} file not found: ${path}`, 2); + } + if (error instanceof SyntaxError) throw new CliError("CLI 参数错误", "INPUT_INVALID_JSON", `${label} file must be valid JSON: ${path}`, 2); + throw error; + } +} + +export function parseBurstFile(path: string): [string[], number[]] { + let raw = readJsonFile(path, "burst"); + if (isRecord(raw) && "bursts" in raw) raw = raw.bursts; + if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) { + const ids = raw.burst_ID.map(String); + const sizes = raw.burst_size.map(Number); + if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2); + return [ids, sizes]; + } + if (Array.isArray(raw)) { + return [ + raw.map((item) => { + if (!isRecord(item) || !("id" in item) || !("size" in item)) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file items must contain id and size", 2); + return String(item.id); + }), + raw.map((item) => Number((item as Record).size)), + ]; + } + throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_ID/burst_size", 2); +} + +export function parseValveSettingFile(path: string): [string[], number[]] { + const raw = readJsonFile(path, "valve-setting"); + if (isRecord(raw) && "valves" in raw && "valves_k" in raw && Array.isArray(raw.valves) && Array.isArray(raw.valves_k)) { + const valves = raw.valves.map(String); + const openings = raw.valves_k.map(Number); + if (valves.length !== openings.length) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valves and valves_k must have the same length", 2); + return [valves, openings]; + } + if (Array.isArray(raw)) { + return [ + raw.map((item) => { + if (!isRecord(item) || !("valve" in item) || !("opening" in item)) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting items must contain valve and opening", 2); + return String(item.valve); + }), + raw.map((item) => Number((item as Record).opening)), + ]; + } + throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting file must be a JSON array or object with valves/valves_k", 2); +} + +export function assignDatasetKeys(target: Record, path: string, keys: string[], label: string): void { + const payload = readJsonFile(path, label); + if (isRecord(payload)) { + for (const key of keys) { + if (key in payload) target[key] = payload[key]; + } + } +} + diff --git a/cli/src/core/http.ts b/cli/src/core/http.ts new file mode 100644 index 0000000..7733c97 --- /dev/null +++ b/cli/src/core/http.ts @@ -0,0 +1,96 @@ +import { CliError, errorMessage } from "./errors.js"; +import { requireNetwork, requireUsername } from "./runtime.js"; +import { success } from "./output.js"; +import type { RequestOptions, RuntimeContext } from "./types.js"; + +function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record { + const out: Record = { + Accept: "application/json, text/plain, */*", + "X-Request-Id": ctx.requestId, + ...ctx.auth.headers, + }; + if (requireAuth) { + if (!ctx.auth.accessToken) { + throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]); + } + out.Authorization = `Bearer ${ctx.auth.accessToken}`; + } else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`; + if (requireProject) { + if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]); + out["X-Project-Id"] = ctx.auth.projectId; + } else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId; + if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId; + return out; +} + +function stringifyParam(value: unknown): string { + if (typeof value === "boolean") return value ? "True" : "False"; + return String(value); +} + +function appendParams(url: URL, params: Record = {}): void { + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item))); + else url.searchParams.set(key, stringifyParam(value)); + } +} + +export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> { + const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request; + if (requireNetworkCtx) requireNetwork(ctx); + if (requireUsernameCtx) requireUsername(ctx); + const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, "")); + appendParams(url, params); + const started = performance.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000); + let response: Response; + try { + response = await fetch(url, { + method: method.toUpperCase(), + headers: { + ...headers(ctx, requireAuth, requireProject), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true); + throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true); + } finally { + clearTimeout(timer); + } + const durationMs = Math.trunc(performance.now() - started); + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + const text = response.status === 204 ? "" : await response.text(); + let payload: unknown = {}; + if (contentType.includes("application/json") && text) payload = JSON.parse(text); + else if (text) payload = { report: text }; + if (!response.ok) { + const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record) : {}; + const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`; + throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500); + } + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const record = payload as Record; + if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload); + } + return [payload, durationMs]; +} + +function mapStatus(status: number): number { + if (status === 400 || status === 422) return 2; + if (status === 401) return 3; + if (status === 403) return 4; + if (status === 404) return 5; + if (status === 409 || status === 412) return 6; + return 7; +} + +export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise { + const [data, durationMs] = await requestJson(ctx, request); + success(summary, data, ctx, durationMs, nextCommands); +} + diff --git a/cli/src/core/options.ts b/cli/src/core/options.ts new file mode 100644 index 0000000..47bbec5 --- /dev/null +++ b/cli/src/core/options.ts @@ -0,0 +1,112 @@ +import { JUNCTION_FIELDS, PIPE_FIELDS, type ElementType } from "./constants.js"; +import { CliError } from "./errors.js"; +import type { OptionSchema, ParsedOptionValue, ParsedOptions } from "./types.js"; + +export function requireValue(argv: string[], index: number, option: string): string { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new CliError("CLI 参数错误", "MISSING_OPTION_VALUE", `${option} requires a value`, 2); + return value; +} + +export function parseIntStrict(value: string, option: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_INTEGER", `${option} must be an integer`, 2); + return parsed; +} + +export function parseFloatStrict(value: string, option: string): number { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_NUMBER", `${option} must be a number`, 2); + return parsed; +} + +export function parseOptions(argv: string[], schema: OptionSchema = {}): ParsedOptions { + const values: Record = {}; + const positionals: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]!; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + const name = arg.slice(2); + const kind = schema[name] ?? "string"; + if (kind === "boolean") { + values[name] = true; + } else { + const raw = requireValue(argv, ++i, arg); + if (kind === "repeat") values[name] = [...asStringArray(values[name]), raw]; + else if (kind === "number") values[name] = parseFloatStrict(raw, arg); + else if (kind === "integer") values[name] = parseIntStrict(raw, arg); + else values[name] = raw; + } + } + return { values, positionals }; +} + +export function required(values: Record, name: string): string | number | boolean | string[] { + const value = values[name]; + if (value === undefined || value === null || value === "") { + throw new CliError("CLI 参数错误", "MISSING_PARAMETER", `Missing option '--${name}'`, 2); + } + return value; +} + +export function requiredString(values: Record, name: string): string { + const value = required(values, name); + if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2); + return value; +} + +export function requiredNumber(values: Record, name: string): number { + const value = required(values, name); + if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2); + return value; +} + +export function requiredStringArray(values: Record, name: string): string[] { + const value = required(values, name); + if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2); + return value; +} + +export function optionalString(values: Record, name: string): string | undefined { + const value = values[name]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2); + return value; +} + +export function optionalNumber(values: Record, name: string): number | undefined { + const value = values[name]; + if (value === undefined) return undefined; + if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2); + return value; +} + +export function optionalStringArray(values: Record, name: string): string[] | undefined { + const value = values[name]; + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2); + return value; +} + +export function asStringArray(value: ParsedOptionValue | undefined): string[] { + if (value === undefined) return []; + if (Array.isArray(value)) return value; + throw new CliError("CLI 参数错误", "INVALID_PARAMETER", "repeat option received a non-array value", 2); +} + +export function validateChoice(value: string, valid: T, option: string): T[number] { + if (!valid.includes(value)) { + throw new CliError("CLI 参数错误", `INVALID_${option.replace(/^--/, "").replaceAll("-", "_").toUpperCase()}`, `${option} must be one of: ${valid.join(", ")}`, 2); + } + return value as T[number]; +} + +export function fieldsFor(type: ElementType): typeof PIPE_FIELDS | typeof JUNCTION_FIELDS { + if (type === "pipe") return PIPE_FIELDS; + if (type === "junction") return JUNCTION_FIELDS; + throw new CliError("CLI 参数错误", "INVALID_TYPE", "--type must be one of: pipe, junction", 2); +} + diff --git a/cli/src/core/output.ts b/cli/src/core/output.ts new file mode 100644 index 0000000..c643880 --- /dev/null +++ b/cli/src/core/output.ts @@ -0,0 +1,61 @@ +import { SCHEMA_VERSION } from "./constants.js"; +import type { RuntimeContext } from "./types.js"; + +export function json(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +export function generatedAt(): string { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export function success(summary: string, data: unknown, ctx: RuntimeContext, durationMs: number, nextCommands: string[] = []): void { + json({ + ok: true, + schema_version: SCHEMA_VERSION, + summary, + data, + metadata: { + request_id: ctx.requestId, + server: ctx.server, + duration_ms: durationMs, + generated_at: generatedAt(), + }, + next_commands: nextCommands, + }); +} + +export function failure({ + summary, + code, + message, + retryable = false, + server = null, + requestId = null, + data = null, + nextCommands = [], +}: { + summary: string; + code: string; + message: string; + retryable?: boolean; + server?: string | null; + requestId?: string | null; + data?: unknown; + nextCommands?: string[]; +}): void { + json({ + ok: false, + schema_version: SCHEMA_VERSION, + summary, + error: { code, message, retryable }, + data, + metadata: { + request_id: requestId, + server, + generated_at: generatedAt(), + }, + next_commands: nextCommands, + }); +} + diff --git a/cli/src/core/runtime.ts b/cli/src/core/runtime.ts new file mode 100644 index 0000000..2b76333 --- /dev/null +++ b/cli/src/core/runtime.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { DEFAULT_SERVER, DEFAULT_TIMEOUT } from "./constants.js"; +import { CliError } from "./errors.js"; +import { requireValue, parseIntStrict } from "./options.js"; +import type { AuthContext, GlobalArgs, ParsedGlobalArgs, RuntimeContext } from "./types.js"; + +function pick(source: Record, ...keys: string[]): string | null { + for (const key of keys) { + const value = source[key]; + if (value !== undefined && value !== null && value !== "") return String(value); + } + return null; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let body = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk: string) => { + body += chunk; + }); + process.stdin.on("end", () => resolve(body)); + process.stdin.on("error", reject); + }); +} + +export async function loadAuthContext(authStdin: boolean): Promise { + const raw = authStdin + ? (JSON.parse(await readStdin()) as Record) + : { + server: process.env.TJWATER_SERVER, + access_token: process.env.TJWATER_ACCESS_TOKEN, + project_id: process.env.TJWATER_PROJECT_ID, + user_id: process.env.TJWATER_USER_ID, + username: process.env.TJWATER_USERNAME, + network: process.env.TJWATER_NETWORK, + headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {}, + }; + const headers = (raw.headers ?? {}) as unknown; + if (!headers || Array.isArray(headers) || typeof headers !== "object") { + throw new CliError("认证失败", "AUTH_CONTEXT_INVALID", "auth context headers must be a JSON object", 3); + } + return { + server: pick(raw, "server", "base_url"), + accessToken: pick(raw, "access_token", "token", "accessToken"), + projectId: pick(raw, "project_id", "projectId", "x_project_id"), + userId: pick(raw, "user_id", "userId", "x_user_id"), + username: pick(raw, "username", "preferred_username"), + network: pick(raw, "network", "project_code", "projectCode", "project"), + headers: Object.fromEntries(Object.entries(headers as Record).map(([key, value]) => [String(key), String(value)])), + }; +} + +export function parseGlobalArgs(argv: string[]): ParsedGlobalArgs { + const globals: GlobalArgs = { + server: null, + authStdin: false, + scheme: null, + timeout: DEFAULT_TIMEOUT, + requestId: null, + }; + const rest: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]!; + if (arg === "--auth-stdin") globals.authStdin = true; + else if (arg === "--server") globals.server = requireValue(argv, ++i, "--server"); + else if (arg === "--scheme") globals.scheme = requireValue(argv, ++i, "--scheme"); + else if (arg === "--timeout") globals.timeout = parseIntStrict(requireValue(argv, ++i, "--timeout"), "--timeout"); + else if (arg === "--request-id") globals.requestId = requireValue(argv, ++i, "--request-id"); + else rest.push(arg); + } + return { globals, rest }; +} + +export async function buildRuntime(globals: GlobalArgs): Promise { + const auth = await loadAuthContext(globals.authStdin); + return { + server: globals.server || auth.server || DEFAULT_SERVER, + auth, + scheme: globals.scheme, + timeout: globals.timeout, + requestId: globals.requestId || randomUUID(), + }; +} + +export function requireNetwork(ctx: RuntimeContext): string { + if (ctx.auth.network) return ctx.auth.network; + throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]); +} + +export function requireUsername(ctx: RuntimeContext): string { + if (ctx.auth.username) return ctx.auth.username; + throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]); +} + +export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null { + const scheme = explicit || ctx.scheme; + if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); + return scheme ?? null; +} + diff --git a/cli/src/core/time.ts b/cli/src/core/time.ts new file mode 100644 index 0000000..47ec21e --- /dev/null +++ b/cli/src/core/time.ts @@ -0,0 +1,23 @@ +import { CliError } from "./errors.js"; + +export function parseTime(value: string, option: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new CliError("CLI 参数错误", "INVALID_TIME", `${option} must be a valid ISO 8601 / RFC 3339 timestamp`, 2); + } + if (!/[zZ]|[+-]\d\d:\d\d$/.test(value)) { + throw new CliError("CLI 参数错误", "TIMEZONE_REQUIRED", `${option} must include an explicit timezone offset`, 2); + } + return value; +} + +export function addMinutesPreservingOffset(value: string, minutes: number): string { + const match = value.match(/([+-])(\d\d):(\d\d)$/); + const end = new Date(new Date(value).getTime() + minutes * 60_000); + if (!match) return end.toISOString().replace(".000Z", "Z"); + const sign = match[1] === "+" ? 1 : -1; + const offsetMinutes = sign * (Number(match[2]) * 60 + Number(match[3])); + const local = new Date(end.getTime() + offsetMinutes * 60_000); + return `${local.toISOString().replace(".000Z", "")}${match[1]}${match[2]}:${match[3]}`; +} + diff --git a/cli/src/core/types.ts b/cli/src/core/types.ts new file mode 100644 index 0000000..caf22a2 --- /dev/null +++ b/cli/src/core/types.ts @@ -0,0 +1,89 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type Dict = Record; + +export interface AuthContext { + server: string | null; + accessToken: string | null; + projectId: string | null; + userId: string | null; + username: string | null; + network: string | null; + headers: Record; +} + +export interface RuntimeContext { + server: string; + auth: AuthContext; + scheme: string | null; + timeout: number; + requestId: string; +} + +export interface GlobalArgs { + server: string | null; + authStdin: boolean; + scheme: string | null; + timeout: number; + requestId: string | null; +} + +export interface ParsedGlobalArgs { + globals: GlobalArgs; + rest: string[]; +} + +export type OptionKind = "string" | "boolean" | "repeat" | "number" | "integer"; +export type OptionSchema = Record; +export type ParsedOptionValue = string | number | boolean | string[]; + +export interface ParsedOptions { + values: Record; + positionals: string[]; +} + +export interface RequestOptions { + method: string; + path: string; + params?: Record; + body?: unknown; + requireAuth?: boolean; + requireProject?: boolean; + requireNetworkCtx?: boolean; + requireUsernameCtx?: boolean; +} + +export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise | void; +export type HandlerMap = Record; + +export interface CommandDoc { + ok: true; + schema_version: string; + command: string; + summary: string; + description: string; + usage: string; + options: CommandOptionDoc[]; + examples: string[]; + output: string; + next_commands: string[]; +} + +export interface CommandOptionDoc { + name: string; + description: string; + required: boolean; + repeated: boolean; + default: null; +} + +export type HelpPayload = + | CommandDoc + | { + ok: true; + schema_version: string; + summary: string; + menu_level?: number; + commands: Array<{ command: string; summary: string; usage?: string; example?: string }>; + }; + diff --git a/cli/src/dispatch.ts b/cli/src/dispatch.ts new file mode 100644 index 0000000..31d580b --- /dev/null +++ b/cli/src/dispatch.ts @@ -0,0 +1,50 @@ +import { CliError } from "./core/errors.js"; +import { handlers } from "./handlers.js"; +import { helpPayload } from "./help/index.js"; +import { failure, json } from "./core/output.js"; +import type { RuntimeContext } from "./core/types.js"; + +export async function dispatch(ctx: RuntimeContext | null, argv: string[]): Promise { + if (argv.length === 0 || argv.includes("--help")) { + process.stdout.write("Usage: tjwater-cli [OPTIONS] COMMAND [ARGS]...\n\nStructured JSON:\n tjwater-cli help\n"); + return; + } + if (argv[0] === "help") { + const payload = helpPayload(argv.slice(1)); + if (!payload) { + failure({ + summary: "未找到命令", + code: "COMMAND_NOT_FOUND", + message: `unknown command path: ${argv.slice(1).join(" ")}`, + retryable: false, + data: { usage: "tjwater-cli help ", examples: ["tjwater-cli help simulation run", "tjwater-cli simulation help"] }, + nextCommands: ["tjwater-cli help", "tjwater-cli help simulation"], + }); + return; + } + json(payload); + return; + } + const matched = matchCommand(argv); + if (!matched) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]); + const handler = handlers[matched.path]; + if (!handler) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]); + if (!ctx && matched.path !== "__noop") throw new CliError("CLI 参数错误", "RUNTIME_REQUIRED", "runtime context is required for command execution", 2); + await handler(ctx!, matched.args); +} + +function matchCommand(argv: string[]): { path: string; args: string[] } | null { + const paths = Object.keys(handlers).sort((a, b) => b.split(" ").length - a.split(" ").length); + for (const path of paths) { + const parts = path.split(" "); + if (parts.every((part, index) => argv[index] === part)) return { path, args: argv.slice(parts.length) }; + } + if (argv.at(-1) === "help") { + const payload = helpPayload(argv.slice(0, -1)); + if (payload) { + json(payload); + return { path: "__noop", args: [] }; + } + } + return null; +} diff --git a/cli/src/handlers.ts b/cli/src/handlers.ts new file mode 100644 index 0000000..7af85f2 --- /dev/null +++ b/cli/src/handlers.ts @@ -0,0 +1,15 @@ +import { analysisHandlers } from "./commands/analysis.js"; +import { componentHandlers } from "./commands/component.js"; +import { dataHandlers } from "./commands/data.js"; +import { networkHandlers } from "./commands/network.js"; +import { simulationHandlers } from "./commands/simulation.js"; +import type { HandlerMap } from "./core/types.js"; + +export const handlers: HandlerMap = { + "__noop": async () => {}, + ...networkHandlers, + ...componentHandlers, + ...simulationHandlers, + ...analysisHandlers, + ...dataHandlers, +}; diff --git a/cli/src/help/docs.ts b/cli/src/help/docs.ts new file mode 100644 index 0000000..15b4b12 --- /dev/null +++ b/cli/src/help/docs.ts @@ -0,0 +1,145 @@ +import { SCHEMA_VERSION } from "../core/constants.js"; +import type { CommandDoc, CommandOptionDoc } from "../core/types.js"; + +export const GROUP_SUMMARIES: Record = { + network: "管网节点、管线等基础属性查询命令。", + component: "组件选项与配置读取命令。", + "component option": "组件选项查询命令。", + simulation: "模拟运行与调度相关命令。", + analysis: "分析计算与诊断相关命令。", + "analysis leakage": "漏损分析相关命令。", + "analysis leakage schemes": "漏损方案查询命令。", + "analysis burst-detection": "爆管检测相关命令。", + "analysis burst-detection schemes": "爆管检测方案查询命令。", + "analysis burst-location": "爆管定位相关命令。", + "analysis burst-location schemes": "爆管定位方案查询命令。", + "analysis risk": "风险分析相关命令。", + "analysis sensor-placement": "传感器选址相关命令。", + data: "时序、SCADA 和方案数据查询命令。", + "data timeseries": "时序数据查询命令。", + "data timeseries realtime": "实时模拟时序查询命令。", + "data timeseries scheme": "方案时序查询命令。", + "data timeseries scada": "SCADA 时序查询命令。", + "data timeseries composite": "复合时序查询命令。", + "data scada": "SCADA 元数据查询命令。", + "data scheme": "方案数据查询命令。", +}; + +export const HIDDEN_PATH_PREFIXES = ["analysis burst-location", "analysis risk"]; + +type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]]; + +const commandSpecs: readonly CommandSpec[] = [ + ["network get-junction-properties", "读取节点属性", ["--junction "], ["tjwater-cli network get-junction-properties --junction J1"]], + ["network get-pipe-properties", "读取管道属性", ["--pipe "], ["tjwater-cli network get-pipe-properties --pipe P1"]], + ["network get-all-pipes-properties", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]], + ["network get-reservoir-properties", "读取水库属性", ["--reservoir "], ["tjwater-cli network get-reservoir-properties --reservoir R1"]], + ["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]], + ["network get-tank-properties", "读取水箱属性", ["--tank "], ["tjwater-cli network get-tank-properties --tank T1"]], + ["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]], + ["network get-pump-properties", "读取水泵属性", ["--pump "], ["tjwater-cli network get-pump-properties --pump PU1"]], + ["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]], + ["network get-valve-properties", "读取阀门属性", ["--valve "], ["tjwater-cli network get-valve-properties --valve V1"]], + ["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]], + ["component option schema", "读取选项 schema", ["--kind ", "[--pump ]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]], + ["component option get", "读取选项属性", ["--kind ", "[--pump ]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]], + ["simulation run", "触发指定绝对时间的模拟运行", ["--start-time ", "--duration "], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]], + ["analysis burst", "执行爆管分析", ["--start-time ", "--duration ", "--burst-file ", "[--scheme ]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list"]], + ["analysis valve", "阀门工况分析。", ["--mode ", "[--start-time ]", "[--valve ]", "[--element ]", "[--disabled-valve ]", "[--duration ]", "[--scheme ]"], ["tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", "tjwater-cli analysis valve --mode isolation --element E1 --element E2", "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3"]], + ["analysis flushing", "执行冲洗分析", ["--start-time ", "--valve-setting-file ", "--drainage-node ", "--flow ", "[--duration ]", "[--scheme ]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]], + ["analysis age", "执行水龄分析", ["--start-time ", "--duration "], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]], + ["analysis contaminant", "执行污染物模拟", ["--start-time ", "--duration ", "--source-node ", "--concentration ", "[--pattern ]", "[--scheme ]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]], + ["analysis sensor-placement kmeans", "执行 KMeans 传感器选址", ["--count ", "[--min-diameter ]", "[--scheme ]"], ["tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01"]], + ["analysis leakage identify", "执行漏损识别", ["--start-time ", "--end-time ", "[--scheme ]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]], + ["analysis leakage schemes list", "列出漏损方案", [], ["tjwater-cli analysis leakage schemes list"]], + ["analysis leakage schemes get", "读取漏损方案详情", [""], ["tjwater-cli analysis leakage schemes get my_scheme"]], + ["analysis burst-detection detect", "执行爆管检测", ["--start-time ", "--end-time ", "[--scheme ]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]], + ["analysis burst-detection schemes list", "列出爆管检测方案", [], ["tjwater-cli analysis burst-detection schemes list"]], + ["analysis burst-detection schemes get", "读取爆管检测方案详情", [""], ["tjwater-cli analysis burst-detection schemes get my_scheme"]], + ["analysis burst-location locate", "执行爆管定位", ["--start-time ", "--end-time ", "--burst-leakage ", "[--scheme ]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]], + ["analysis burst-location schemes list", "列出爆管定位方案", [], ["tjwater-cli analysis burst-location schemes list"]], + ["analysis burst-location schemes get", "读取爆管定位方案详情", [""], ["tjwater-cli analysis burst-location schemes get my_scheme"]], + ["analysis risk pipe-now", "读取单条管道当前风险", ["--pipe "], ["tjwater-cli analysis risk pipe-now --pipe P1"]], + ["analysis risk pipe-history", "读取单条管道历史风险", ["--pipe "], ["tjwater-cli analysis risk pipe-history --pipe P1"]], + ["analysis risk network", "读取全网风险", [], ["tjwater-cli analysis risk network"]], + ["data timeseries realtime links", "查询实时管道时序", ["--start-time ", "--end-time "], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]], + ["data timeseries realtime nodes", "查询实时节点时序", ["--start-time ", "--end-time "], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]], + ["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id ", "--type ", "--time