jpdcl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/catalog.d.ts +141 -0
- package/dist/catalog.js +261 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +395 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +100 -0
- package/dist/credentials.js.map +1 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +21 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dates.d.ts +5 -0
- package/dist/dates.js +20 -0
- package/dist/dates.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +23 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-handler.d.ts +1 -0
- package/dist/http-handler.js +46 -0
- package/dist/http-handler.js.map +1 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.js +43 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +11 -0
- package/dist/launcher.js.map +1 -0
- package/dist/ledger-client.d.ts +112 -0
- package/dist/ledger-client.js +221 -0
- package/dist/ledger-client.js.map +1 -0
- package/dist/main-client.d.ts +25 -0
- package/dist/main-client.js +221 -0
- package/dist/main-client.js.map +1 -0
- package/dist/mcp-stdio.d.ts +2 -0
- package/dist/mcp-stdio.js +6 -0
- package/dist/mcp-stdio.js.map +1 -0
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.js +517 -0
- package/dist/mcp.js.map +1 -0
- package/dist/runtime.d.ts +27 -0
- package/dist/runtime.js +316 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +221 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +30 -0
- package/dist/session.js.map +1 -0
- package/dist/smart-client.d.ts +50 -0
- package/dist/smart-client.js +238 -0
- package/dist/smart-client.js.map +1 -0
- package/dist/tariff.d.ts +121 -0
- package/dist/tariff.js +124 -0
- package/dist/tariff.js.map +1 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.js +3 -0
- package/dist/worker.js.map +1 -0
- package/openapi.yaml +284 -0
- package/package.json +89 -0
- package/scripts/audit-http.ts +36 -0
- package/scripts/audit-launcher.mjs +28 -0
- package/scripts/audit-ledger-portal.ts +23 -0
- package/scripts/audit-mcp.mjs +140 -0
- package/scripts/audit-smart-portal.mjs +63 -0
- package/scripts/build.mjs +19 -0
- package/scripts/publish-smithery.mjs +47 -0
- package/src/catalog.ts +291 -0
- package/src/cli.ts +397 -0
- package/src/config.ts +15 -0
- package/src/credentials.ts +114 -0
- package/src/crypto.ts +21 -0
- package/src/dates.ts +19 -0
- package/src/errors.ts +26 -0
- package/src/http-handler.ts +48 -0
- package/src/http.ts +49 -0
- package/src/index.ts +13 -0
- package/src/launcher.ts +9 -0
- package/src/ledger-client.ts +258 -0
- package/src/main-client.ts +230 -0
- package/src/mcp-stdio.ts +6 -0
- package/src/mcp.ts +546 -0
- package/src/runtime.ts +329 -0
- package/src/server.ts +218 -0
- package/src/session.ts +29 -0
- package/src/smart-client.ts +249 -0
- package/src/tariff.ts +148 -0
- package/src/toolkit.test.ts +136 -0
- package/src/types.ts +51 -0
- package/src/worker.ts +3 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +16 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { JpdclError } from "./errors.js";
|
|
2
|
+
import { resolveCredentials, type Credentials } from "./credentials.js";
|
|
3
|
+
import { MAIN_API_URL, mutationsEnabled } from "./config.js";
|
|
4
|
+
import { JpdclClient } from "./main-client.js";
|
|
5
|
+
import { JpdclLedgerClient, ledgerPeriod, type LedgerPeriodOptions } from "./ledger-client.js";
|
|
6
|
+
import { loadSession, saveSession } from "./session.js";
|
|
7
|
+
import { decodeJwt, SmartMeterClient, smartApiUrlFromAppUrl } from "./smart-client.js";
|
|
8
|
+
import { calculateDomesticMeteredCharges, type DomesticTariffInput } from "./tariff.js";
|
|
9
|
+
import type { MainSession, PortalResponse } from "./types.js";
|
|
10
|
+
|
|
11
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
12
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
13
|
+
? (value as Record<string, unknown>)
|
|
14
|
+
: undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class JpdclRuntime {
|
|
18
|
+
readonly main: JpdclClient;
|
|
19
|
+
readonly ledger = new JpdclLedgerClient();
|
|
20
|
+
smart?: SmartMeterClient;
|
|
21
|
+
|
|
22
|
+
private constructor(
|
|
23
|
+
session: MainSession | undefined,
|
|
24
|
+
private readonly options: {
|
|
25
|
+
credentials?: Credentials;
|
|
26
|
+
persistent: boolean;
|
|
27
|
+
allowMutations: boolean;
|
|
28
|
+
},
|
|
29
|
+
) {
|
|
30
|
+
this.main = new JpdclClient(
|
|
31
|
+
session,
|
|
32
|
+
MAIN_API_URL,
|
|
33
|
+
options.allowMutations,
|
|
34
|
+
options.credentials,
|
|
35
|
+
options.persistent ? saveSession : async () => {},
|
|
36
|
+
);
|
|
37
|
+
if (session?.smart?.token) {
|
|
38
|
+
this.smart = new SmartMeterClient(session.smart.token, session.smart.baseUrl, options.allowMutations, session.smart.tenantId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async create(options: {
|
|
43
|
+
credentials?: Credentials;
|
|
44
|
+
session?: MainSession;
|
|
45
|
+
persistent?: boolean;
|
|
46
|
+
allowMutations?: boolean;
|
|
47
|
+
} = {}): Promise<JpdclRuntime> {
|
|
48
|
+
const persistent = options.persistent ?? true;
|
|
49
|
+
const session = options.session ?? (persistent ? await loadSession() : undefined);
|
|
50
|
+
return new JpdclRuntime(session, {
|
|
51
|
+
credentials: options.credentials,
|
|
52
|
+
persistent,
|
|
53
|
+
allowMutations: options.allowMutations ?? mutationsEnabled(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async login(loginId: string, password: string): Promise<PortalResponse> {
|
|
58
|
+
const response = await this.main.login(loginId, password);
|
|
59
|
+
await this.persist();
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async persist(): Promise<void> {
|
|
64
|
+
if (!this.options.persistent) return;
|
|
65
|
+
const session = this.main.currentSession;
|
|
66
|
+
if (!session) return;
|
|
67
|
+
if (this.smart?.bearerToken) {
|
|
68
|
+
session.smart = {
|
|
69
|
+
token: this.smart.bearerToken,
|
|
70
|
+
baseUrl: this.smart.baseUrl,
|
|
71
|
+
tenantId: this.smart.effectiveTenantId,
|
|
72
|
+
accountId: this.smart.accountId,
|
|
73
|
+
meterNumber: this.smart.meterNumber,
|
|
74
|
+
userId: typeof this.smart.claims.sub === "string" ? this.smart.claims.sub : undefined,
|
|
75
|
+
expiresAt: this.smart.expiresAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
await saveSession(session);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async ensureLogin(): Promise<void> {
|
|
82
|
+
if (this.main.currentSession) return;
|
|
83
|
+
const credentials = this.options.credentials ?? await resolveCredentials();
|
|
84
|
+
if (!credentials) {
|
|
85
|
+
throw new JpdclError("Run `jpdcl auth login` or set JPDCL_LOGIN_ID and JPDCL_PASSWORD", 401);
|
|
86
|
+
}
|
|
87
|
+
await this.login(credentials.loginId, credentials.password);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async ensureSmart(accountId?: string, meterNumber?: string): Promise<SmartMeterClient> {
|
|
91
|
+
const smartMatches = this.smart?.bearerToken
|
|
92
|
+
&& (!accountId || accountId === this.smart.accountId)
|
|
93
|
+
&& (!meterNumber || meterNumber === this.smart.meterNumber)
|
|
94
|
+
&& (!this.smart.expiresAt || new Date(this.smart.expiresAt).getTime() > Date.now() + 30_000);
|
|
95
|
+
if (smartMatches) return this.smart!;
|
|
96
|
+
await this.ensureLogin();
|
|
97
|
+
const customer = await this.main.customerInfo(accountId);
|
|
98
|
+
const customerData = record(customer.data) ?? {};
|
|
99
|
+
const resolvedAccount = accountId ?? this.main.currentSession?.primaryAccountId;
|
|
100
|
+
const resolvedMeter = meterNumber ?? String(
|
|
101
|
+
customerData.mtrSrNum ?? customerData.meterNumber ?? customerData.meterNo ?? "",
|
|
102
|
+
);
|
|
103
|
+
if (!resolvedAccount || !resolvedMeter) throw new JpdclError("Account ID and meter number are required", 400);
|
|
104
|
+
const sso = await this.main.smartSso(resolvedAccount, resolvedMeter);
|
|
105
|
+
const ssoData = record(sso.data) ?? {};
|
|
106
|
+
const jwt = ssoData.jwt;
|
|
107
|
+
if (typeof jwt !== "string") throw new JpdclError(sso.message ?? "JPDCL did not issue a smart-meter token", 502, sso);
|
|
108
|
+
const appUrl = typeof ssoData.app_url === "string" ? ssoData.app_url : undefined;
|
|
109
|
+
const claims = decodeJwt(jwt);
|
|
110
|
+
const tenantId = typeof claims.currentAccountTenantId === "string" ? claims.currentAccountTenantId : undefined;
|
|
111
|
+
this.smart = new SmartMeterClient(jwt, smartApiUrlFromAppUrl(appUrl), this.options.allowMutations, tenantId);
|
|
112
|
+
await this.persist();
|
|
113
|
+
return this.smart;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async aiSnapshot(accountId?: string): Promise<Record<string, unknown>> {
|
|
117
|
+
await this.ensureLogin();
|
|
118
|
+
const main = await this.main.digest(accountId);
|
|
119
|
+
const resolvedAccount = accountId ?? this.main.currentSession?.primaryAccountId;
|
|
120
|
+
const errors: Array<{ source: string; message: string }> = [];
|
|
121
|
+
const [smartResult, ledgerResult, tariffResult] = await Promise.all([
|
|
122
|
+
capture((async () => {
|
|
123
|
+
const client = await this.ensureSmart(resolvedAccount);
|
|
124
|
+
return (await client.dashboard(resolvedAccount, { includeDerived: false })).data;
|
|
125
|
+
})()),
|
|
126
|
+
capture(this.energyLedger(resolvedAccount, { limit: 7 })),
|
|
127
|
+
capture(this.tariffEstimate(resolvedAccount)),
|
|
128
|
+
]);
|
|
129
|
+
if (!smartResult.ok) errors.push({ source: "smart-meter", message: smartResult.error });
|
|
130
|
+
if (!ledgerResult.ok) errors.push({ source: "daily-meter-ledger", message: ledgerResult.error });
|
|
131
|
+
if (!tariffResult.ok) errors.push({ source: "tariff-calculation", message: tariffResult.error });
|
|
132
|
+
const smart = smartResult.ok ? smartResult.value : null;
|
|
133
|
+
const ledger = ledgerResult.ok ? ledgerResult.value : null;
|
|
134
|
+
const tariff = tariffResult.ok ? tariffResult.value : null;
|
|
135
|
+
await this.persist();
|
|
136
|
+
return {
|
|
137
|
+
schemaVersion: "1.0",
|
|
138
|
+
generatedAt: new Date().toISOString(),
|
|
139
|
+
dataPolicy: {
|
|
140
|
+
mode: "observed-plus-deterministic-calculations",
|
|
141
|
+
excludes: ["forecasts", "recommendations", "energy-saving tips", "smartTips"],
|
|
142
|
+
calculated: [
|
|
143
|
+
"daily and period usage from cumulative register subtraction",
|
|
144
|
+
"provisional billable kWh from the net-import register for net-meter accounts",
|
|
145
|
+
"tariff estimate from observed kWh and official published rates",
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
identity: {
|
|
149
|
+
accountId: resolvedAccount ?? null,
|
|
150
|
+
meterNumber: this.smart?.meterNumber ?? record(main.meter)?.number ?? null,
|
|
151
|
+
plan: this.smart ? (String(this.smart.claims.currentAccountIsMeterPrepaid).toLowerCase() === "true" ? "prepaid" : "postpaid") : null,
|
|
152
|
+
},
|
|
153
|
+
sources: {
|
|
154
|
+
mainPortal: { available: true, kind: "account-and-billing-records" },
|
|
155
|
+
smartMeter: { available: smart !== null, kind: "meter-and-consumer-api-records" },
|
|
156
|
+
dailyMeterLedger: { available: ledger !== null, kind: "daily-import-export-registers" },
|
|
157
|
+
},
|
|
158
|
+
data: { main, smart, ledger, tariff },
|
|
159
|
+
errors,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async tariffEstimate(accountId?: string, overrides: Partial<DomesticTariffInput> = {}): Promise<Record<string, unknown>> {
|
|
164
|
+
await this.ensureLogin();
|
|
165
|
+
const customerResponse = await this.main.customerInfo(accountId);
|
|
166
|
+
const customer = record(customerResponse.data) ?? {};
|
|
167
|
+
const resolvedAccount = accountId ?? this.main.currentSession?.primaryAccountId;
|
|
168
|
+
const category = String(customer.consumerType ?? customer.saTypeCd ?? customer.category ?? "").toUpperCase();
|
|
169
|
+
if (!category.includes("DOM") && category !== "1") {
|
|
170
|
+
throw new JpdclError(`Automatic tariff calculation currently supports domestic metered accounts; portal category is ${category || "unknown"}`, 422);
|
|
171
|
+
}
|
|
172
|
+
const sanctionedLoadKw = overrides.sanctionedLoadKw ?? Number(customer.sanctionedLoad);
|
|
173
|
+
if (!Number.isFinite(sanctionedLoadKw)) throw new JpdclError("The portal did not provide a valid sanctioned load", 422);
|
|
174
|
+
|
|
175
|
+
let unitsKwh = overrides.unitsKwh;
|
|
176
|
+
let usageSource: Record<string, unknown> = { kind: "manual" };
|
|
177
|
+
if (unitsKwh === undefined) {
|
|
178
|
+
const consumerId = String(customer.consumerID ?? "");
|
|
179
|
+
try {
|
|
180
|
+
const daily = await this.ledger.consumer(consumerId);
|
|
181
|
+
const period = ledgerPeriod(daily, { limit: 0 });
|
|
182
|
+
const periodRecord = record(period.period) ?? {};
|
|
183
|
+
unitsKwh = Number(periodRecord.provisionalBillableKwh);
|
|
184
|
+
usageSource = {
|
|
185
|
+
kind: "daily-register-ledger",
|
|
186
|
+
endpoint: "ledger_consumer_readings",
|
|
187
|
+
netMeter: daily.consumer.netMeter,
|
|
188
|
+
basis: periodRecord.provisionalBillableBasis,
|
|
189
|
+
from: periodRecord.from,
|
|
190
|
+
to: periodRecord.to,
|
|
191
|
+
latestObservation: periodRecord.latestObservation,
|
|
192
|
+
warning: periodRecord.warning,
|
|
193
|
+
};
|
|
194
|
+
} catch (ledgerError) {
|
|
195
|
+
const client = await this.ensureSmart(resolvedAccount);
|
|
196
|
+
if (!client.meterNumber) throw new JpdclError("The smart-meter number is unavailable", 422);
|
|
197
|
+
const usage = await client.request("smart_today_monthly", { params: { meterNumber: client.meterNumber } });
|
|
198
|
+
const usageData = record(usage.data) ?? {};
|
|
199
|
+
unitsKwh = Number(usageData.monthlyConsumption);
|
|
200
|
+
usageSource = {
|
|
201
|
+
kind: "smart-meter-current-month-fallback",
|
|
202
|
+
endpoint: "smart_today_monthly",
|
|
203
|
+
latestDate: usageData.latestDate ?? null,
|
|
204
|
+
unit: usageData.unit ?? "kWh",
|
|
205
|
+
ledgerError: ledgerError instanceof Error ? ledgerError.message : String(ledgerError),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!Number.isFinite(unitsKwh)) throw new JpdclError("The portal did not provide valid current-month consumption; pass unitsKwh explicitly", 422);
|
|
210
|
+
const prepaid = overrides.prepaid ?? String(customer.postOrPre ?? "").toUpperCase().includes("PREPAID");
|
|
211
|
+
const calculation = calculateDomesticMeteredCharges({
|
|
212
|
+
...overrides,
|
|
213
|
+
unitsKwh,
|
|
214
|
+
sanctionedLoadKw,
|
|
215
|
+
prepaid,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
generatedAt: new Date().toISOString(),
|
|
219
|
+
account: {
|
|
220
|
+
accountId: resolvedAccount ?? null,
|
|
221
|
+
portalCategory: customer.consumerType ?? customer.saTypeCd ?? customer.category ?? null,
|
|
222
|
+
mappedTariff: "Domestic Supply - Metered Consumer",
|
|
223
|
+
plan: prepaid ? "prepaid" : "postpaid",
|
|
224
|
+
consumerId: customer.consumerID ?? null,
|
|
225
|
+
},
|
|
226
|
+
usageSource,
|
|
227
|
+
...calculation,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async energyLedger(accountId?: string, options: LedgerPeriodOptions = {}): Promise<Record<string, unknown>> {
|
|
232
|
+
await this.ensureLogin();
|
|
233
|
+
const customer = record((await this.main.customerInfo(accountId)).data) ?? {};
|
|
234
|
+
const consumerId = String(customer.consumerID ?? "");
|
|
235
|
+
if (!consumerId) throw new JpdclError("The main portal did not provide a consumer ID", 422);
|
|
236
|
+
const data = await this.ledger.consumer(consumerId);
|
|
237
|
+
return ledgerPeriod(data, options);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async meterHealth(accountId?: string): Promise<Record<string, unknown>> {
|
|
241
|
+
await this.ensureLogin();
|
|
242
|
+
const customer = record((await this.main.customerInfo(accountId)).data) ?? {};
|
|
243
|
+
const resolvedAccount = accountId ?? this.main.currentSession?.primaryAccountId;
|
|
244
|
+
if (!resolvedAccount) throw new JpdclError("An account ID is required", 400);
|
|
245
|
+
const consumerId = String(customer.consumerID ?? "");
|
|
246
|
+
const client = await this.ensureSmart(resolvedAccount);
|
|
247
|
+
if (!client.meterNumber) throw new JpdclError("The smart-meter number is unavailable", 422);
|
|
248
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
249
|
+
const from = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
|
|
250
|
+
const [connection, onDemand, onDemandLogs, voltage, powerEvents, ledger, alarms] = await Promise.all([
|
|
251
|
+
capture(this.main.request("main_meter_status", { body: { account_id: resolvedAccount } })),
|
|
252
|
+
capture(client.request("smart_meter_reading", { params: { meterNumber: client.meterNumber } })),
|
|
253
|
+
capture(client.request("smart_on_demand_logs", { params: { meterNumber: client.meterNumber } })),
|
|
254
|
+
capture(client.report("ConsumerVoltageDataProfile", resolvedAccount, { from, to: today, start: 1, end: 100 })),
|
|
255
|
+
capture(client.report("PowerOnOff", resolvedAccount, { from, to: today, start: 1, end: 100 })),
|
|
256
|
+
capture(consumerId ? this.ledger.consumer(consumerId) : Promise.reject(new Error("Consumer ID unavailable"))),
|
|
257
|
+
capture(this.ledger.alarms(client.meterNumber)),
|
|
258
|
+
]);
|
|
259
|
+
const connectionData = recordResponseData(connection);
|
|
260
|
+
const demandData = recordResponseData(onDemand);
|
|
261
|
+
const demandReadings = Array.isArray(demandData?.data) ? demandData.data : [];
|
|
262
|
+
const demandStatus = Number(demandData?.status);
|
|
263
|
+
const logsData = responseData(onDemandLogs);
|
|
264
|
+
const latestLog = Array.isArray(logsData) ? logsData[0] : null;
|
|
265
|
+
const voltageEnvelope = record(responseData(voltage));
|
|
266
|
+
const voltageRows = Array.isArray(voltageEnvelope?.data) ? voltageEnvelope.data : [];
|
|
267
|
+
const powerEnvelope = record(responseData(powerEvents));
|
|
268
|
+
const powerRows = Array.isArray(powerEnvelope?.data) ? powerEnvelope.data : [];
|
|
269
|
+
const ledgerData = ledger.ok ? ledger.value : undefined;
|
|
270
|
+
return {
|
|
271
|
+
_meta: {
|
|
272
|
+
dataClass: "observed-status-with-explicit-inferences",
|
|
273
|
+
generatedAt: new Date().toISOString(),
|
|
274
|
+
networkConnectivity: "not directly exposed by any consumer endpoint",
|
|
275
|
+
},
|
|
276
|
+
identity: { accountId: resolvedAccount, consumerId, meterNumber: client.meterNumber },
|
|
277
|
+
supply: {
|
|
278
|
+
state: connectionData?.meterConnectionStatus ?? null,
|
|
279
|
+
asOf: connectionData?.currentDate ?? null,
|
|
280
|
+
source: "main_meter_status",
|
|
281
|
+
},
|
|
282
|
+
communication: {
|
|
283
|
+
explicitOnlineStatus: null,
|
|
284
|
+
onDemandRequestAllowed: typeof demandData?.isRequestAllowed === "boolean" ? demandData.isRequestAllowed : null,
|
|
285
|
+
onDemandStatus: Number.isFinite(demandStatus) ? ({ 1: "pending", 2: "failed", 3: "completed" } as Record<number, string>)[demandStatus] ?? "unknown" : null,
|
|
286
|
+
latestRequest: latestLog,
|
|
287
|
+
},
|
|
288
|
+
instantaneousReadings: {
|
|
289
|
+
source: "smart_meter_reading/on-demand",
|
|
290
|
+
readings: demandReadings,
|
|
291
|
+
warning: "These are the latest on-demand values returned by the utility, not a continuous stream.",
|
|
292
|
+
},
|
|
293
|
+
voltageProfile: {
|
|
294
|
+
source: "ConsumerVoltageDataProfile",
|
|
295
|
+
latest: voltageRows[0] ?? null,
|
|
296
|
+
recordCount: voltageRows.length,
|
|
297
|
+
},
|
|
298
|
+
powerEvents: {
|
|
299
|
+
source: "PowerOnOff",
|
|
300
|
+
latest: powerRows[0] ?? null,
|
|
301
|
+
openEvent: powerRows.some((event) => event && typeof event === "object" && !(event as Record<string, unknown>).powerOffEnd),
|
|
302
|
+
recordCount: powerRows.length,
|
|
303
|
+
},
|
|
304
|
+
dailyLedger: ledgerData && typeof ledgerData === "object" && "readings" in ledgerData ? {
|
|
305
|
+
latest: (ledgerData as { readings: unknown[] }).readings[0] ?? null,
|
|
306
|
+
totalRecords: (ledgerData as { readings: unknown[] }).readings.length,
|
|
307
|
+
} : null,
|
|
308
|
+
alarms: alarms.ok ? alarms.value : { unavailable: true, error: alarms.error },
|
|
309
|
+
sourceErrors: [connection, onDemand, onDemandLogs, voltage, powerEvents, ledger, alarms]
|
|
310
|
+
.filter((result) => !result.ok)
|
|
311
|
+
.map((result) => result.error),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function capture<T>(promise: Promise<T>): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
|
|
317
|
+
try { return { ok: true, value: await promise }; }
|
|
318
|
+
catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function responseData(result: { ok: true; value: unknown } | { ok: false; error: string }): unknown {
|
|
322
|
+
if (!result.ok) return undefined;
|
|
323
|
+
const value = record(result.value);
|
|
324
|
+
return value && "data" in value ? value.data : result.value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function recordResponseData(result: { ok: true; value: unknown } | { ok: false; error: string }): Record<string, unknown> | undefined {
|
|
328
|
+
return record(responseData(result));
|
|
329
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import { endpointCatalog, isEndpointName, listEndpoints } from "./catalog.js";
|
|
6
|
+
import { credentialStatus } from "./credentials.js";
|
|
7
|
+
import { JpdclError } from "./errors.js";
|
|
8
|
+
import { JpdclRuntime } from "./runtime.js";
|
|
9
|
+
import { JPDCL_TARIFF_ORDER_2025_26, type DomesticTariffInput } from "./tariff.js";
|
|
10
|
+
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
const runtime = await JpdclRuntime.create();
|
|
13
|
+
const openApiDocument = await fs.readFile(new URL("../openapi.yaml", import.meta.url), "utf8");
|
|
14
|
+
|
|
15
|
+
app.use("/v1/*", async (c, next) => {
|
|
16
|
+
const configuredKey = process.env.JPDCL_API_KEY;
|
|
17
|
+
if (configuredKey && c.req.header("authorization") !== `Bearer ${configuredKey}`) {
|
|
18
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
19
|
+
}
|
|
20
|
+
await next();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.onError((error, c) => {
|
|
24
|
+
const known = error instanceof JpdclError;
|
|
25
|
+
return c.json({
|
|
26
|
+
error: known ? error.name : "InternalError",
|
|
27
|
+
message: error.message,
|
|
28
|
+
details: known ? error.details : undefined,
|
|
29
|
+
}, (known && error.status && error.status >= 400 && error.status <= 599 ? error.status : 500) as 400);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.get("/health", (c) => c.json({ status: "ok", service: "jpdcl-api", version: "1.0.0" }));
|
|
33
|
+
app.get("/", (c) => c.json({ service: "jpdcl-api", health: "/health", openapi: "/openapi.yaml", catalog: "/v1/catalog" }));
|
|
34
|
+
app.get("/openapi.yaml", (c) => c.text(openApiDocument, 200, { "Content-Type": "application/yaml; charset=utf-8" }));
|
|
35
|
+
app.get("/v1/catalog", (c) => c.json(listEndpoints(c.req.query("portal") as "main" | "smart" | "ledger" | undefined)));
|
|
36
|
+
app.get("/v1/tariff", (c) => c.json(JPDCL_TARIFF_ORDER_2025_26));
|
|
37
|
+
|
|
38
|
+
app.post("/v1/auth/login", async (c) => {
|
|
39
|
+
const body = await c.req.json<{ loginId?: string; password?: string }>();
|
|
40
|
+
if (!body.loginId || !body.password) throw new JpdclError("loginId and password are required", 400);
|
|
41
|
+
const result = await runtime.login(body.loginId, body.password);
|
|
42
|
+
return c.json({ status: result.status, message: result.message ?? "Login successful" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get("/v1/me", async (c) => {
|
|
46
|
+
await runtime.ensureLogin();
|
|
47
|
+
const session = runtime.main.currentSession;
|
|
48
|
+
const automatic = await credentialStatus();
|
|
49
|
+
return c.json({
|
|
50
|
+
loginId: session?.loginId,
|
|
51
|
+
primaryAccountId: session?.primaryAccountId,
|
|
52
|
+
smartAuthenticated: Boolean(session?.smart),
|
|
53
|
+
smartExpiresAt: session?.smart?.expiresAt,
|
|
54
|
+
automaticRelogin: automatic.automaticRelogin,
|
|
55
|
+
credentialSource: automatic.source,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
app.get("/v1/snapshot", async (c) => c.json(await runtime.aiSnapshot(c.req.query("accountId"))));
|
|
59
|
+
app.get("/v1/accounts/:accountId", async (c) => {
|
|
60
|
+
await runtime.ensureLogin();
|
|
61
|
+
return c.json(await runtime.main.customerInfo(c.req.param("accountId")));
|
|
62
|
+
});
|
|
63
|
+
app.get("/v1/accounts/:accountId/digest", async (c) => {
|
|
64
|
+
await runtime.ensureLogin();
|
|
65
|
+
return c.json(await runtime.main.digest(c.req.param("accountId")));
|
|
66
|
+
});
|
|
67
|
+
app.get("/v1/accounts/:accountId/bills", async (c) => {
|
|
68
|
+
await runtime.ensureLogin();
|
|
69
|
+
return c.json(await runtime.main.history("BILL", c.req.param("accountId"), c.req.query("from"), c.req.query("to")));
|
|
70
|
+
});
|
|
71
|
+
app.get("/v1/accounts/:accountId/payments", async (c) => {
|
|
72
|
+
await runtime.ensureLogin();
|
|
73
|
+
return c.json(await runtime.main.history("PAYM", c.req.param("accountId"), c.req.query("from"), c.req.query("to")));
|
|
74
|
+
});
|
|
75
|
+
app.get("/v1/accounts/:accountId/consumption", async (c) => {
|
|
76
|
+
await runtime.ensureLogin();
|
|
77
|
+
return c.json(await runtime.main.consumption(c.req.param("accountId"), c.req.query("from"), c.req.query("to")));
|
|
78
|
+
});
|
|
79
|
+
app.get("/v1/accounts/:accountId/tariff-estimate", async (c) => {
|
|
80
|
+
const query = c.req.query();
|
|
81
|
+
const overrides: Partial<DomesticTariffInput> = {};
|
|
82
|
+
assignQueryNumber(overrides, "unitsKwh", query.units);
|
|
83
|
+
assignQueryNumber(overrides, "sanctionedLoadKw", query.load);
|
|
84
|
+
assignQueryNumber(overrides, "electricityDutyAmount", query.electricityDuty);
|
|
85
|
+
assignQueryNumber(overrides, "otherChargesAmount", query.otherCharges);
|
|
86
|
+
assignQueryNumber(overrides, "unpaidPrincipalAmount", query.unpaidPrincipal);
|
|
87
|
+
assignQueryNumber(overrides, "lateMonths", query.lateMonths);
|
|
88
|
+
if (query.prepaid !== undefined) overrides.prepaid = query.prepaid === "true";
|
|
89
|
+
if (query.solarWaterHeaterEligible !== undefined) overrides.solarWaterHeaterEligible = query.solarWaterHeaterEligible === "true";
|
|
90
|
+
return c.json(await runtime.tariffEstimate(c.req.param("accountId"), overrides));
|
|
91
|
+
});
|
|
92
|
+
app.get("/v1/accounts/:accountId/energy-ledger", async (c) => {
|
|
93
|
+
const limit = c.req.query("limit") === undefined ? 35 : Number(c.req.query("limit"));
|
|
94
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 500) throw new JpdclError("limit must be an integer from 0 to 500", 400);
|
|
95
|
+
return c.json(await runtime.energyLedger(c.req.param("accountId"), {
|
|
96
|
+
from: c.req.query("from"),
|
|
97
|
+
to: c.req.query("to"),
|
|
98
|
+
limit,
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
app.get("/v1/accounts/:accountId/meter-health", async (c) => {
|
|
102
|
+
return c.json(await runtime.meterHealth(c.req.param("accountId")));
|
|
103
|
+
});
|
|
104
|
+
app.get("/v1/accounts/:accountId/meter-changes", async (c) => {
|
|
105
|
+
await runtime.ensureLogin();
|
|
106
|
+
return c.json(await runtime.main.request("main_meter_changes", { body: { accountid: c.req.param("accountId") } }));
|
|
107
|
+
});
|
|
108
|
+
app.get("/v1/linked-accounts", async (c) => {
|
|
109
|
+
await runtime.ensureLogin();
|
|
110
|
+
return c.json(await runtime.main.linkedAccounts());
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.get("/v1/smart/session", async (c) => c.json(await (await runtime.ensureSmart()).connections()));
|
|
114
|
+
app.get("/v1/smart/accounts/:accountId/dashboard", async (c) => {
|
|
115
|
+
const accountId = c.req.param("accountId");
|
|
116
|
+
return c.json(await (await runtime.ensureSmart(accountId)).dashboard(accountId, {
|
|
117
|
+
includeDerived: c.req.query("includeDerived") === "true",
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
app.get("/v1/smart/accounts/:accountId/meter", async (c) => {
|
|
121
|
+
const accountId = c.req.param("accountId");
|
|
122
|
+
const client = await runtime.ensureSmart(accountId);
|
|
123
|
+
if (!client.meterNumber) throw new JpdclError("Smart-meter number is unavailable", 400);
|
|
124
|
+
const [details, reading] = await Promise.all([
|
|
125
|
+
client.request("smart_meter_details", { params: { meterNumber: client.meterNumber } }),
|
|
126
|
+
client.request("smart_current_meter_reading", { params: { accountId } }),
|
|
127
|
+
]);
|
|
128
|
+
return c.json({ details: details.data, currentReading: reading.data });
|
|
129
|
+
});
|
|
130
|
+
app.get("/v1/smart/accounts/:accountId/alerts", async (c) => {
|
|
131
|
+
const accountId = c.req.param("accountId");
|
|
132
|
+
const client = await runtime.ensureSmart(accountId);
|
|
133
|
+
if (!client.meterNumber) throw new JpdclError("Smart-meter number is unavailable", 400);
|
|
134
|
+
return c.json(await client.request("smart_my_alerts", { params: { accountId, meterNumber: client.meterNumber } }));
|
|
135
|
+
});
|
|
136
|
+
app.get("/v1/smart/accounts/:accountId/preferences", async (c) => {
|
|
137
|
+
const client = await runtime.ensureSmart(c.req.param("accountId"));
|
|
138
|
+
const isPrepaid = String(client.claims.currentAccountIsMeterPrepaid).toLowerCase() === "true";
|
|
139
|
+
return c.json(await client.request("smart_preferences", { params: { isPrepaid } }));
|
|
140
|
+
});
|
|
141
|
+
app.get("/v1/smart/accounts/:accountId/notifications", async (c) => {
|
|
142
|
+
const client = await runtime.ensureSmart(c.req.param("accountId"));
|
|
143
|
+
const userId = typeof client.claims.sub === "string" ? client.claims.sub : undefined;
|
|
144
|
+
if (!userId) throw new JpdclError("Smart user ID is unavailable", 400);
|
|
145
|
+
const [items, unread] = await Promise.all([
|
|
146
|
+
client.request("smart_notifications", { params: { userId } }).catch(() => ({ status: true, data: [] })),
|
|
147
|
+
client.request("smart_notification_unread_count", { params: { userId } }).catch(() => ({ status: true, data: { unreadCount: 0 } })),
|
|
148
|
+
]);
|
|
149
|
+
return c.json({ items: items.data, unread: unread.data });
|
|
150
|
+
});
|
|
151
|
+
app.get("/v1/smart/accounts/:accountId/support", async (c) => {
|
|
152
|
+
const client = await runtime.ensureSmart(c.req.param("accountId"));
|
|
153
|
+
const userId = typeof client.claims.sub === "string" ? client.claims.sub : undefined;
|
|
154
|
+
if (!userId) throw new JpdclError("Smart user ID is unavailable", 400);
|
|
155
|
+
const [faqs, contact, categories, complaints] = await Promise.all([
|
|
156
|
+
client.request("smart_faqs"), client.request("smart_contact_support").catch(() => ({ status: true, data: null })), client.request("smart_complaint_categories"),
|
|
157
|
+
client.request("smart_complaints", { params: { userId, pageNumber: Number(c.req.query("page") ?? 1), pageSize: Number(c.req.query("pageSize") ?? 20), statusCodes: c.req.query("statusCodes") } }),
|
|
158
|
+
]);
|
|
159
|
+
return c.json({ faqs: faqs.data, contact: contact.data, categories: categories.data, complaints: complaints.data });
|
|
160
|
+
});
|
|
161
|
+
app.get("/v1/smart/accounts/:accountId/consumption", async (c) => {
|
|
162
|
+
const accountId = c.req.param("accountId");
|
|
163
|
+
return c.json(await (await runtime.ensureSmart(accountId)).consumption(accountId, c.req.query("type") ?? "monthly", Number(c.req.query("value") ?? 12)));
|
|
164
|
+
});
|
|
165
|
+
app.get("/v1/smart/accounts/:accountId/intervals", async (c) => {
|
|
166
|
+
const accountId = c.req.param("accountId");
|
|
167
|
+
const from = c.req.query("from");
|
|
168
|
+
const to = c.req.query("to");
|
|
169
|
+
if (!from || !to) throw new JpdclError("from and to (YYYY-MM-DD) are required", 400);
|
|
170
|
+
return c.json(await (await runtime.ensureSmart(accountId)).intervalConsumption(accountId, from, to, c.req.query("sort") ?? "date"));
|
|
171
|
+
});
|
|
172
|
+
app.get("/v1/smart/accounts/:accountId/reports/:reportType", async (c) => {
|
|
173
|
+
const accountId = c.req.param("accountId");
|
|
174
|
+
return c.json(await (await runtime.ensureSmart(accountId)).report(c.req.param("reportType") as never, accountId, {
|
|
175
|
+
from: c.req.query("from"),
|
|
176
|
+
to: c.req.query("to"),
|
|
177
|
+
start: c.req.query("start") ? Number(c.req.query("start")) : undefined,
|
|
178
|
+
end: c.req.query("end") ? Number(c.req.query("end")) : undefined,
|
|
179
|
+
filter: c.req.query("filter"),
|
|
180
|
+
format: c.req.query("format") as "xlsx" | "pdf" | undefined,
|
|
181
|
+
}));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
app.all("/v1/raw/:endpoint", async (c) => {
|
|
185
|
+
const name = c.req.param("endpoint");
|
|
186
|
+
if (!isEndpointName(name)) throw new JpdclError(`Unknown endpoint: ${name}`, 404);
|
|
187
|
+
const requestUrl = new URL(c.req.url);
|
|
188
|
+
const input = c.req.method === "GET"
|
|
189
|
+
? { params: Object.fromEntries(requestUrl.searchParams.entries()), confirm: requestUrl.searchParams.get("confirm") === "true" }
|
|
190
|
+
: await c.req.json<{ params?: Record<string, string>; body?: unknown; confirm?: boolean }>();
|
|
191
|
+
const definition = endpointCatalog[name];
|
|
192
|
+
if (definition.mutation && !input.confirm) {
|
|
193
|
+
throw new JpdclError("Mutating endpoints require confirm=true and JPDCL_ENABLE_MUTATIONS=true", 400);
|
|
194
|
+
}
|
|
195
|
+
if (input.params) delete input.params.confirm;
|
|
196
|
+
const result = definition.portal === "main"
|
|
197
|
+
? await runtime.main.request(name, input)
|
|
198
|
+
: definition.portal === "smart"
|
|
199
|
+
? await (await runtime.ensureSmart()).request(name, input)
|
|
200
|
+
: await runtime.ledger.request(name, input);
|
|
201
|
+
await runtime.persist();
|
|
202
|
+
return c.json(result);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const port = Number(process.env.JPDCL_API_PORT ?? 8787);
|
|
206
|
+
const hostname = process.env.JPDCL_API_HOST ?? "127.0.0.1";
|
|
207
|
+
serve({ fetch: app.fetch, port, hostname }, (info) => {
|
|
208
|
+
process.stderr.write(`JPDCL API listening on http://${hostname}:${info.port}\n`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export { app };
|
|
212
|
+
|
|
213
|
+
function assignQueryNumber<T extends object, K extends keyof T>(target: T, key: K, raw: string | undefined): void {
|
|
214
|
+
if (raw === undefined) return;
|
|
215
|
+
const value = Number(raw);
|
|
216
|
+
if (!Number.isFinite(value) || value < 0) throw new JpdclError(`${String(key)} must be a non-negative number`, 400);
|
|
217
|
+
target[key] = value as T[K];
|
|
218
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultSessionFile } from "./config.js";
|
|
4
|
+
import type { MainSession } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export async function loadSession(file = defaultSessionFile): Promise<MainSession | undefined> {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(await fs.readFile(file, "utf8")) as MainSession;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function saveSession(session: MainSession, file = defaultSessionFile): Promise<void> {
|
|
16
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
17
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
18
|
+
await fs.writeFile(temporary, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
19
|
+
await fs.rename(temporary, file);
|
|
20
|
+
await fs.chmod(file, 0o600);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function clearSession(file = defaultSessionFile): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
await fs.unlink(file);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|