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.
Files changed (103) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +219 -0
  4. package/dist/catalog.d.ts +141 -0
  5. package/dist/catalog.js +261 -0
  6. package/dist/catalog.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +395 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +13 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/credentials.d.ts +13 -0
  14. package/dist/credentials.js +100 -0
  15. package/dist/credentials.js.map +1 -0
  16. package/dist/crypto.d.ts +3 -0
  17. package/dist/crypto.js +21 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dates.d.ts +5 -0
  20. package/dist/dates.js +20 -0
  21. package/dist/dates.js.map +1 -0
  22. package/dist/errors.d.ts +11 -0
  23. package/dist/errors.js +23 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/http-handler.d.ts +1 -0
  26. package/dist/http-handler.js +46 -0
  27. package/dist/http-handler.js.map +1 -0
  28. package/dist/http.d.ts +9 -0
  29. package/dist/http.js +43 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +13 -0
  32. package/dist/index.js +14 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/launcher.d.ts +2 -0
  35. package/dist/launcher.js +11 -0
  36. package/dist/launcher.js.map +1 -0
  37. package/dist/ledger-client.d.ts +112 -0
  38. package/dist/ledger-client.js +221 -0
  39. package/dist/ledger-client.js.map +1 -0
  40. package/dist/main-client.d.ts +25 -0
  41. package/dist/main-client.js +221 -0
  42. package/dist/main-client.js.map +1 -0
  43. package/dist/mcp-stdio.d.ts +2 -0
  44. package/dist/mcp-stdio.js +6 -0
  45. package/dist/mcp-stdio.js.map +1 -0
  46. package/dist/mcp.d.ts +8 -0
  47. package/dist/mcp.js +517 -0
  48. package/dist/mcp.js.map +1 -0
  49. package/dist/runtime.d.ts +27 -0
  50. package/dist/runtime.js +316 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/server.d.ts +4 -0
  53. package/dist/server.js +221 -0
  54. package/dist/server.js.map +1 -0
  55. package/dist/session.d.ts +4 -0
  56. package/dist/session.js +30 -0
  57. package/dist/session.js.map +1 -0
  58. package/dist/smart-client.d.ts +50 -0
  59. package/dist/smart-client.js +238 -0
  60. package/dist/smart-client.js.map +1 -0
  61. package/dist/tariff.d.ts +121 -0
  62. package/dist/tariff.js +124 -0
  63. package/dist/tariff.js.map +1 -0
  64. package/dist/types.d.ts +51 -0
  65. package/dist/types.js +2 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/worker.d.ts +5 -0
  68. package/dist/worker.js +3 -0
  69. package/dist/worker.js.map +1 -0
  70. package/openapi.yaml +284 -0
  71. package/package.json +89 -0
  72. package/scripts/audit-http.ts +36 -0
  73. package/scripts/audit-launcher.mjs +28 -0
  74. package/scripts/audit-ledger-portal.ts +23 -0
  75. package/scripts/audit-mcp.mjs +140 -0
  76. package/scripts/audit-smart-portal.mjs +63 -0
  77. package/scripts/build.mjs +19 -0
  78. package/scripts/publish-smithery.mjs +47 -0
  79. package/src/catalog.ts +291 -0
  80. package/src/cli.ts +397 -0
  81. package/src/config.ts +15 -0
  82. package/src/credentials.ts +114 -0
  83. package/src/crypto.ts +21 -0
  84. package/src/dates.ts +19 -0
  85. package/src/errors.ts +26 -0
  86. package/src/http-handler.ts +48 -0
  87. package/src/http.ts +49 -0
  88. package/src/index.ts +13 -0
  89. package/src/launcher.ts +9 -0
  90. package/src/ledger-client.ts +258 -0
  91. package/src/main-client.ts +230 -0
  92. package/src/mcp-stdio.ts +6 -0
  93. package/src/mcp.ts +546 -0
  94. package/src/runtime.ts +329 -0
  95. package/src/server.ts +218 -0
  96. package/src/session.ts +29 -0
  97. package/src/smart-client.ts +249 -0
  98. package/src/tariff.ts +148 -0
  99. package/src/toolkit.test.ts +136 -0
  100. package/src/types.ts +51 -0
  101. package/src/worker.ts +3 -0
  102. package/tsconfig.build.json +11 -0
  103. package/tsconfig.json +16 -0
package/src/http.ts ADDED
@@ -0,0 +1,49 @@
1
+ import makeFetchCookie from "fetch-cookie";
2
+ import { CookieJar } from "tough-cookie";
3
+
4
+ export class CookieHttpClient {
5
+ readonly jar: CookieJar;
6
+ readonly fetch: typeof globalThis.fetch;
7
+
8
+ constructor(serializedCookies?: string) {
9
+ this.jar = serializedCookies
10
+ ? CookieJar.deserializeSync(JSON.parse(serializedCookies))
11
+ : new CookieJar();
12
+ this.fetch = makeFetchCookie(globalThis.fetch, this.jar) as typeof globalThis.fetch;
13
+ }
14
+
15
+ serialize(): string {
16
+ return JSON.stringify(this.jar.serializeSync());
17
+ }
18
+ }
19
+ export function basicAuth(value: string): string {
20
+ return `Basic ${Buffer.from(value, "utf8").toString("base64")}`;
21
+ }
22
+
23
+ export function buildUrl(
24
+ baseUrl: string,
25
+ path: string,
26
+ params: Record<string, string | number | boolean | null | undefined> = {},
27
+ ): string {
28
+ let resolved = path;
29
+ const unused = { ...params };
30
+ for (const [key, rawValue] of Object.entries(params)) {
31
+ const marker = `{${key}}`;
32
+ if (resolved.includes(marker)) {
33
+ if (rawValue === undefined || rawValue === null) {
34
+ throw new Error(`Missing required URL parameter: ${key}`);
35
+ }
36
+ resolved = resolved.replaceAll(marker, encodeURIComponent(String(rawValue)));
37
+ delete unused[key];
38
+ }
39
+ }
40
+ const missing = [...resolved.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
41
+ if (missing.length) throw new Error(`Missing required URL parameter(s): ${missing.join(", ")}`);
42
+
43
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
44
+ const url = new URL(resolved.replace(/^\//, ""), base);
45
+ for (const [key, value] of Object.entries(unused)) {
46
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
47
+ }
48
+ return url.toString();
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./catalog.js";
2
+ export * from "./config.js";
3
+ export * from "./crypto.js";
4
+ export * from "./credentials.js";
5
+ export * from "./errors.js";
6
+ export * from "./main-client.js";
7
+ export * from "./ledger-client.js";
8
+ export * from "./dates.js";
9
+ export * from "./runtime.js";
10
+ export * from "./session.js";
11
+ export * from "./smart-client.js";
12
+ export * from "./tariff.js";
13
+ export * from "./types.js";
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ // MCP clients start the package without arguments. Any explicit argument selects
4
+ // the human CLI, so `npx jpdcl` and `jpdcl auth status` can share one executable.
5
+ if (process.argv.length > 2) {
6
+ await import("./cli.js");
7
+ } else {
8
+ await import("./mcp-stdio.js");
9
+ }
@@ -0,0 +1,258 @@
1
+ import * as cheerio from "cheerio";
2
+ import { assertDateRange } from "./dates.js";
3
+ import { JpdclError } from "./errors.js";
4
+ import type { EndpointName } from "./catalog.js";
5
+ import type { RequestOptions } from "./types.js";
6
+
7
+ export const LEDGER_PORTAL_URL = "https://smartmeter1.jpdcl.co.in/smartmeter/";
8
+ export const LEDGER_CONSUMER_ENDPOINT = new URL("assets/php/_getConsumerDetails.php", LEDGER_PORTAL_URL).toString();
9
+ export const LEDGER_ALARM_ENDPOINT = new URL("assets/php/_getAlarmDetails.php", LEDGER_PORTAL_URL).toString();
10
+
11
+ export interface LedgerReading {
12
+ observedAt: string;
13
+ cumulative: {
14
+ importKwh: number;
15
+ exportKwh: number;
16
+ netImportKwh: number;
17
+ importKvah: number;
18
+ exportKvah: number;
19
+ netImportKvah: number;
20
+ };
21
+ }
22
+
23
+ export interface LedgerConsumerData {
24
+ _meta: {
25
+ dataClass: "observed";
26
+ source: "jpdcl-daily-meter-ledger";
27
+ sourceUrl: string;
28
+ fetchedAt: string;
29
+ publicConsumerLookup: true;
30
+ };
31
+ consumer: {
32
+ consumerId: string;
33
+ name: string | null;
34
+ smartMeterNumber: string | null;
35
+ installationDate: string | null;
36
+ netMeter: boolean | null;
37
+ earthLoading: boolean | null;
38
+ };
39
+ readings: LedgerReading[];
40
+ }
41
+
42
+ export interface LedgerPeriodOptions {
43
+ from?: string;
44
+ to?: string;
45
+ limit?: number;
46
+ }
47
+
48
+ export class JpdclLedgerClient {
49
+ private readonly cache = new Map<string, { at: number; value: LedgerConsumerData }>();
50
+
51
+ constructor(private readonly timeoutMs = 20_000, private readonly cacheMs = 30_000) {}
52
+
53
+ async consumer(consumerId: string): Promise<LedgerConsumerData> {
54
+ validateConsumerId(consumerId);
55
+ const cached = this.cache.get(consumerId);
56
+ if (cached && Date.now() - cached.at < this.cacheMs) return cached.value;
57
+ const response = await postForm(LEDGER_CONSUMER_ENDPOINT, { consumercode: consumerId }, this.timeoutMs);
58
+ if (!response.ok) throw new JpdclError(`Daily meter portal returned HTTP ${response.status}`, response.status);
59
+ const parsed = parseLedgerHtml(await response.text(), consumerId);
60
+ this.cache.set(consumerId, { at: Date.now(), value: parsed });
61
+ return parsed;
62
+ }
63
+
64
+ async alarms(meterNumber: string): Promise<Record<string, unknown>> {
65
+ if (!meterNumber.trim()) throw new JpdclError("A smart-meter number is required", 400);
66
+ const response = await postForm(LEDGER_ALARM_ENDPOINT, { meterSno: meterNumber.trim() }, this.timeoutMs);
67
+ if (!response.ok) throw new JpdclError(`Daily meter alarm endpoint returned HTTP ${response.status}`, response.status);
68
+ const body = await response.text();
69
+ const noAlarms = /no\s+alarms/i.test(body);
70
+ const tables = noAlarms ? [] : parseGenericTables(body);
71
+ return {
72
+ _meta: {
73
+ dataClass: "observed",
74
+ source: "jpdcl-daily-meter-ledger-alarm-endpoint",
75
+ sourceUrl: LEDGER_ALARM_ENDPOINT,
76
+ fetchedAt: new Date().toISOString(),
77
+ },
78
+ meterNumber,
79
+ hasAlarms: noAlarms ? false : tables.some((table) => table.rows.length > 0),
80
+ tables,
81
+ message: noAlarms ? "No Alarms" : undefined,
82
+ };
83
+ }
84
+
85
+ async request(name: EndpointName | string, options: RequestOptions = {}): Promise<unknown> {
86
+ if (name === "ledger_consumer_readings") {
87
+ const consumerId = String(options.params?.consumerId ?? (options.body as Record<string, unknown> | undefined)?.consumerId ?? "");
88
+ return this.consumer(consumerId);
89
+ }
90
+ if (name === "ledger_meter_alarms") {
91
+ const meterNumber = String(options.params?.meterNumber ?? (options.body as Record<string, unknown> | undefined)?.meterNumber ?? "");
92
+ return this.alarms(meterNumber);
93
+ }
94
+ throw new JpdclError(`Unknown daily meter ledger endpoint: ${name}`, 400);
95
+ }
96
+ }
97
+
98
+ export function parseLedgerHtml(html: string, expectedConsumerId: string): LedgerConsumerData {
99
+ const [profileHtml, tableHtml = ""] = html.split("||||");
100
+ const profile = parseProfile(profileHtml ?? "");
101
+ const $ = cheerio.load(tableHtml);
102
+ const readings: LedgerReading[] = [];
103
+ $("#readtable1 tbody tr").each((_, row) => {
104
+ const cells = $(row).find("td").map((__, cell) => $(cell).text().trim()).get();
105
+ if (cells.length < 7 || !/^\d{4}-\d{2}-\d{2}$/.test(cells[0] ?? "")) return;
106
+ const values = cells.slice(1, 7).map(Number);
107
+ if (values.some((value) => !Number.isFinite(value))) return;
108
+ readings.push({
109
+ observedAt: `${cells[0]}T00:00:00+05:30`,
110
+ cumulative: {
111
+ importKwh: values[0]!, exportKwh: values[1]!, netImportKwh: values[2]!,
112
+ importKvah: values[3]!, exportKvah: values[4]!, netImportKvah: values[5]!,
113
+ },
114
+ });
115
+ });
116
+ readings.sort((a, b) => b.observedAt.localeCompare(a.observedAt));
117
+ if (!readings.length) throw new JpdclError("No daily meter readings were returned for this consumer ID", 404);
118
+ const consumerId = profile["consumer id"] || expectedConsumerId;
119
+ return {
120
+ _meta: {
121
+ dataClass: "observed",
122
+ source: "jpdcl-daily-meter-ledger",
123
+ sourceUrl: LEDGER_CONSUMER_ENDPOINT,
124
+ fetchedAt: new Date().toISOString(),
125
+ publicConsumerLookup: true,
126
+ },
127
+ consumer: {
128
+ consumerId,
129
+ name: nullable(profile.name),
130
+ smartMeterNumber: nullable(profile["smart meter"]),
131
+ installationDate: validDate(profile["inatallation date"] ?? profile["installation date"]),
132
+ netMeter: yesNo(profile["net meter"]),
133
+ earthLoading: yesNo(profile["earth-loading"]),
134
+ },
135
+ readings,
136
+ };
137
+ }
138
+
139
+ export function ledgerPeriod(data: LedgerConsumerData, options: LedgerPeriodOptions = {}) {
140
+ const newestDate = data.readings[0]?.observedAt.slice(0, 10);
141
+ if (!newestDate) throw new JpdclError("The daily meter ledger is empty", 404);
142
+ const from = options.from ?? `${newestDate.slice(0, 7)}-01`;
143
+ const to = options.to ?? newestDate;
144
+ assertDateRange(from, to, { requireBoth: true });
145
+ const ascending = [...data.readings].sort((a, b) => a.observedAt.localeCompare(b.observedAt));
146
+ const selected = ascending.filter((reading) => {
147
+ const day = reading.observedAt.slice(0, 10);
148
+ return day >= from && day <= to;
149
+ });
150
+ const baseline = [...ascending].reverse().find((reading) => reading.observedAt.slice(0, 10) < from) ?? selected[0];
151
+ const latest = selected.at(-1);
152
+ const deltas = new Map<string, ReturnType<typeof subtractReadings>>();
153
+ for (let index = 1; index < ascending.length; index++) {
154
+ deltas.set(ascending[index]!.observedAt, subtractReadings(ascending[index]!, ascending[index - 1]!));
155
+ }
156
+ const requestedLimit = Math.max(0, Math.floor(options.limit ?? 35));
157
+ const returned = (requestedLimit === 0 ? [] : [...selected].reverse().slice(0, requestedLimit)).map((reading) => ({
158
+ ...reading,
159
+ usageSincePreviousObservation: deltas.get(reading.observedAt) ?? null,
160
+ }));
161
+ const periodUsage = baseline && latest ? subtractReadings(latest, baseline) : null;
162
+ const settlementCandidateKwh = periodUsage
163
+ ? data.consumer.netMeter ? Math.max(0, periodUsage.netImportKwh) : Math.max(0, periodUsage.importKwh)
164
+ : null;
165
+ const latestDay = new Date(`${newestDate}T00:00:00+05:30`).getTime();
166
+ return {
167
+ _meta: {
168
+ dataClass: "observed-with-deterministic-deltas",
169
+ source: data._meta.source,
170
+ fetchedAt: data._meta.fetchedAt,
171
+ calculation: "cumulative register subtraction",
172
+ },
173
+ consumer: data.consumer,
174
+ availability: {
175
+ firstObservation: data.readings.at(-1)?.observedAt ?? null,
176
+ latestObservation: data.readings[0]?.observedAt ?? null,
177
+ sourceRecordCount: data.readings.length,
178
+ freshnessHoursAtFetch: Math.max(0, Math.round((new Date(data._meta.fetchedAt).getTime() - latestDay) / 360_000) / 10),
179
+ },
180
+ period: {
181
+ from,
182
+ to,
183
+ baselineObservation: baseline?.observedAt ?? null,
184
+ latestObservation: latest?.observedAt ?? null,
185
+ usage: periodUsage,
186
+ provisionalBillableKwh: settlementCandidateKwh,
187
+ provisionalBillableBasis: data.consumer.netMeter ? "net-import-register-difference" : "import-register-difference",
188
+ warning: "Register differences are meter evidence, but JPDCL may use different billing cutoffs, adjustments, carry-forward credits, or settlement rules.",
189
+ },
190
+ readings: returned,
191
+ pagination: { returned: returned.length, matched: selected.length, limit: requestedLimit || null },
192
+ };
193
+ }
194
+
195
+ function subtractReadings(current: LedgerReading, previous: LedgerReading) {
196
+ const result: Record<string, number> = {};
197
+ for (const key of Object.keys(current.cumulative) as Array<keyof LedgerReading["cumulative"]>) {
198
+ result[key] = round3(current.cumulative[key] - previous.cumulative[key]);
199
+ }
200
+ return result as LedgerReading["cumulative"];
201
+ }
202
+
203
+ function parseProfile(html: string): Record<string, string> {
204
+ const $ = cheerio.load(html);
205
+ const profile: Record<string, string> = {};
206
+ $(".row").each((_, row) => {
207
+ const columns = $(row).find(".col").map((__, column) => $(column).text().replace(/\s+/g, " ").trim()).get();
208
+ const key = columns[0]?.toLowerCase();
209
+ if (!key) return;
210
+ profile[key] = $(row).find("input").attr("value")?.trim() ?? columns[1] ?? "";
211
+ });
212
+ return profile;
213
+ }
214
+
215
+ function parseGenericTables(html: string) {
216
+ const $ = cheerio.load(html);
217
+ return $("table").map((_, table) => {
218
+ const headers = $(table).find("thead th").map((__, cell) => $(cell).text().replace(/\s+/g, " ").trim()).get();
219
+ const rows = $(table).find("tbody tr").map((__, row) => [$(row).find("td").map((___, cell) => $(cell).text().replace(/\s+/g, " ").trim()).get()]).get();
220
+ return { headers, rows };
221
+ }).get();
222
+ }
223
+
224
+ async function postForm(url: string, fields: Record<string, string>, timeoutMs: number): Promise<Response> {
225
+ return fetch(url, {
226
+ method: "POST",
227
+ headers: {
228
+ Accept: "text/html, */*; q=0.01",
229
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
230
+ "X-Requested-With": "XMLHttpRequest",
231
+ },
232
+ body: new URLSearchParams(fields),
233
+ signal: AbortSignal.timeout(timeoutMs),
234
+ });
235
+ }
236
+
237
+ function validateConsumerId(value: string): void {
238
+ if (!/^\d{1,13}$/.test(value)) throw new JpdclError("Consumer ID must contain 1 to 13 digits", 400);
239
+ }
240
+
241
+ function nullable(value: string | undefined): string | null {
242
+ return value?.trim() ? value.trim() : null;
243
+ }
244
+
245
+ function validDate(value: string | undefined): string | null {
246
+ return value && /^\d{4}-\d{2}-\d{2}$/.test(value) && value !== "0000-00-00" ? value : null;
247
+ }
248
+
249
+ function yesNo(value: string | undefined): boolean | null {
250
+ if (!value) return null;
251
+ if (/^yes$/i.test(value.trim())) return true;
252
+ if (/^no$/i.test(value.trim())) return false;
253
+ return null;
254
+ }
255
+
256
+ function round3(value: number): number {
257
+ return Math.round((value + Number.EPSILON) * 1000) / 1000;
258
+ }
@@ -0,0 +1,230 @@
1
+ import { endpointCatalog, isEndpointName, type EndpointName } from "./catalog.js";
2
+ import { LOGIN_CREDENTIAL_HEADER, MAIN_API_URL, PORTAL_BASIC, mutationsEnabled } from "./config.js";
3
+ import { decryptPayload, encryptPayload } from "./crypto.js";
4
+ import { assertDateRange } from "./dates.js";
5
+ import { resolveCredentials, type Credentials } from "./credentials.js";
6
+ import { AuthenticationError, JpdclError, MutationDisabledError } from "./errors.js";
7
+ import { basicAuth, buildUrl, CookieHttpClient } from "./http.js";
8
+ import type { MainSession, PortalResponse, RequestOptions } from "./types.js";
9
+ import { saveSession } from "./session.js";
10
+
11
+ function truthyStatus(status: unknown): boolean {
12
+ return status === true || status === 1 || status === "1" || status === "true" || status === "Success" || status === "0";
13
+ }
14
+
15
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
16
+ return value && typeof value === "object" && !Array.isArray(value)
17
+ ? (value as Record<string, unknown>)
18
+ : undefined;
19
+ }
20
+
21
+ export class JpdclClient {
22
+ private readonly http: CookieHttpClient;
23
+ private session?: MainSession;
24
+
25
+ constructor(
26
+ session?: MainSession,
27
+ private readonly baseUrl = MAIN_API_URL,
28
+ private readonly allowMutations = mutationsEnabled(),
29
+ private readonly suppliedCredentials?: Credentials,
30
+ private readonly persistSession: (session: MainSession) => Promise<void> = saveSession,
31
+ ) {
32
+ this.session = session;
33
+ this.http = new CookieHttpClient(session?.cookies);
34
+ }
35
+
36
+ get currentSession(): MainSession | undefined {
37
+ if (!this.session) return undefined;
38
+ return { ...this.session, cookies: this.http.serialize(), updatedAt: new Date().toISOString() };
39
+ }
40
+
41
+ async login(loginId: string, password: string): Promise<PortalResponse> {
42
+ if (!loginId || !password) throw new AuthenticationError("Login ID and password are required");
43
+ const response = await this.http.fetch(buildUrl(this.baseUrl, "/userLoginWebNew"), {
44
+ method: "GET",
45
+ headers: {
46
+ Accept: "application/json, text/plain, */*",
47
+ Authorization: basicAuth(PORTAL_BASIC),
48
+ [LOGIN_CREDENTIAL_HEADER]: Buffer.from(`${loginId}:${password}`, "utf8").toString("base64"),
49
+ },
50
+ });
51
+ const decoded = await this.decodeResponse(response);
52
+ if (!response.ok || !truthyStatus(decoded.status)) {
53
+ throw new AuthenticationError(decoded.message ?? `Login failed with HTTP ${response.status}`, decoded);
54
+ }
55
+
56
+ const data = asRecord(decoded.data) ?? {};
57
+ const now = new Date().toISOString();
58
+ this.session = {
59
+ version: 1,
60
+ createdAt: now,
61
+ updatedAt: now,
62
+ cookies: this.http.serialize(),
63
+ loginId: String(data.login_id ?? loginId),
64
+ primaryAccountId: data.primary_acc_number ? String(data.primary_acc_number) : undefined,
65
+ user: data,
66
+ };
67
+ return decoded;
68
+ }
69
+
70
+ async request<T = unknown>(name: EndpointName | string, options: RequestOptions = {}): Promise<PortalResponse<T>> {
71
+ return this.requestInternal<T>(name, options, false);
72
+ }
73
+
74
+ private async requestInternal<T>(name: EndpointName | string, options: RequestOptions, retriedAuthentication: boolean): Promise<PortalResponse<T>> {
75
+ if (!isEndpointName(name)) throw new JpdclError(`Unknown endpoint: ${name}`, 400);
76
+ const endpoint = endpointCatalog[name];
77
+ if (endpoint.portal !== "main") throw new JpdclError(`${name} belongs to the smart-meter portal`, 400);
78
+ if (endpoint.mutation && !this.allowMutations) throw new MutationDisabledError();
79
+ if (name === "main_login") throw new JpdclError("Use login() for consumer authentication", 400);
80
+ if (endpoint.auth === "main-session" && !this.session) throw new AuthenticationError();
81
+
82
+ const url = buildUrl(this.baseUrl, endpoint.path, options.params);
83
+ const headers: Record<string, string> = {
84
+ Accept: "application/json, text/plain, */*",
85
+ ...options.headers,
86
+ };
87
+ if (endpoint.auth === "public-basic" || endpoint.auth === "external-basic") {
88
+ headers.Authorization = basicAuth(PORTAL_BASIC);
89
+ }
90
+
91
+ let body: string | undefined;
92
+ if (endpoint.method !== "GET" && endpoint.method !== "DELETE") {
93
+ headers["Content-Type"] = "application/json";
94
+ body = JSON.stringify(endpoint.encrypted === false ? (options.body ?? {}) : encryptPayload(options.body ?? {}));
95
+ }
96
+
97
+ const response = await this.http.fetch(url, { method: endpoint.method, headers, body });
98
+ const decoded = await this.decodeResponse<T>(response);
99
+ if (!retriedAuthentication && endpoint.auth === "main-session" && isAuthenticationFailure(response.status, decoded)) {
100
+ const credentials = this.suppliedCredentials ?? await resolveCredentials();
101
+ if (credentials) {
102
+ const smartSession = this.session?.smart;
103
+ await this.login(credentials.loginId, credentials.password);
104
+ if (this.session && smartSession) this.session.smart = smartSession;
105
+ const refreshedSession = this.currentSession;
106
+ if (refreshedSession) await this.persistSession(refreshedSession);
107
+ return this.requestInternal<T>(name, options, true);
108
+ }
109
+ }
110
+ if (!response.ok) throw new JpdclError(decoded.message ?? `JPDCL returned HTTP ${response.status}`, response.status, decoded);
111
+ return decoded;
112
+ }
113
+
114
+ private async decodeResponse<T = unknown>(response: Response): Promise<PortalResponse<T>> {
115
+ const text = await response.text();
116
+ if (!text) return { status: response.ok };
117
+ try {
118
+ return decryptPayload<PortalResponse<T>>(text);
119
+ } catch (decryptError) {
120
+ try {
121
+ return JSON.parse(text) as PortalResponse<T>;
122
+ } catch {
123
+ throw new JpdclError("JPDCL returned an unreadable response", response.status, {
124
+ contentType: response.headers.get("content-type"),
125
+ decryptError: (decryptError as Error).message,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ async customerInfo(accountId = this.session?.primaryAccountId): Promise<PortalResponse> {
132
+ if (!accountId) throw new JpdclError("An account ID is required", 400);
133
+ const result = await this.request("main_customer_info", { body: { accountid: accountId } });
134
+ const record = asRecord(result.data);
135
+ if (record && this.session) {
136
+ this.session.consumerCode = record.consumerID ? String(record.consumerID) : this.session.consumerCode;
137
+ }
138
+ return result;
139
+ }
140
+
141
+ async linkedAccounts(loginId = this.session?.loginId): Promise<PortalResponse> {
142
+ if (!loginId) throw new JpdclError("A login ID is required", 400);
143
+ return this.request("main_linked_accounts", { body: { loginid: loginId } });
144
+ }
145
+
146
+ async history(
147
+ type: "BILL" | "PAYM",
148
+ accountId = this.session?.primaryAccountId,
149
+ from = sixMonthsAgo(),
150
+ to = isoDate(new Date()),
151
+ ): Promise<PortalResponse> {
152
+ if (!accountId) throw new JpdclError("An account ID is required", 400);
153
+ assertDateRange(from, to, { requireBoth: true });
154
+ return this.request("main_bill_history", {
155
+ body: { acct_id: accountId, type, st_dt: from, en_dt: to },
156
+ });
157
+ }
158
+
159
+ async consumption(
160
+ accountId = this.session?.primaryAccountId,
161
+ from = sixMonthsAgo(),
162
+ to = isoDate(new Date()),
163
+ ): Promise<PortalResponse> {
164
+ if (!accountId) throw new JpdclError("An account ID is required", 400);
165
+ assertDateRange(from, to, { requireBoth: true });
166
+ return this.request("main_consumption", {
167
+ body: { accountid: accountId, fromdate: from, todate: to },
168
+ });
169
+ }
170
+
171
+ async smartSso(accountId: string, meterNumber: string): Promise<PortalResponse> {
172
+ return this.request("main_smart_sso", { body: { accountId, mtrno: meterNumber } });
173
+ }
174
+
175
+ async digest(accountId = this.session?.primaryAccountId): Promise<Record<string, unknown>> {
176
+ if (!accountId) throw new JpdclError("An account ID is required", 400);
177
+ const customer = await this.customerInfo(accountId);
178
+ const optional = await Promise.all([
179
+ capturePortal("bills", this.history("BILL", accountId)),
180
+ capturePortal("payments", this.history("PAYM", accountId)),
181
+ capturePortal("consumption", this.consumption(accountId)),
182
+ capturePortal("linkedAccounts", this.linkedAccounts()),
183
+ ]);
184
+ const [bills, payments, consumption, linked] = optional;
185
+ const customerData = asRecord(customer.data) ?? {};
186
+ return {
187
+ _meta: {
188
+ dataClass: "observed-account-and-billing-records",
189
+ source: "jpdcl-wss",
190
+ sourceErrors: optional.filter((result) => !result.ok).map((result) => ({ source: result.source, message: result.error })),
191
+ },
192
+ generatedAt: new Date().toISOString(),
193
+ accountId,
194
+ consumer: customerData,
195
+ currentBill: customerData.billDetails ?? customerData.currentBill ?? null,
196
+ meter: {
197
+ number: customerData.mtrSrNum ?? customerData.meterNo ?? customerData.meterNumber,
198
+ accountType: customerData.postOrPre ?? customerData.accountType,
199
+ netMetering: customerData.isNetMtrAcct,
200
+ },
201
+ bills: bills.ok ? bills.value.data ?? [] : [],
202
+ payments: payments.ok ? payments.value.data ?? [] : [],
203
+ consumption: consumption.ok ? consumption.value.data ?? [] : [],
204
+ linkedAccounts: linked.ok ? linked.value.data ?? [] : [],
205
+ };
206
+ }
207
+ }
208
+
209
+ async function capturePortal(source: string, promise: Promise<PortalResponse>): Promise<
210
+ { ok: true; source: string; value: PortalResponse } | { ok: false; source: string; error: string }
211
+ > {
212
+ try { return { ok: true, source, value: await promise }; }
213
+ catch (error) { return { ok: false, source, error: error instanceof Error ? error.message : String(error) }; }
214
+ }
215
+
216
+ function isAuthenticationFailure(httpStatus: number, response: PortalResponse): boolean {
217
+ if (httpStatus === 401 || httpStatus === 403) return true;
218
+ const message = String(response.message ?? "").toLowerCase();
219
+ return ["unauthor", "session expired", "login again", "not authenticated"].some((marker) => message.includes(marker));
220
+ }
221
+
222
+ export function isoDate(date: Date): string {
223
+ return date.toISOString().slice(0, 10);
224
+ }
225
+
226
+ export function sixMonthsAgo(): string {
227
+ const date = new Date();
228
+ date.setMonth(date.getMonth() - 6);
229
+ return isoDate(date);
230
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createJpdclMcpServer } from "./mcp.js";
4
+
5
+ const server = await createJpdclMcpServer();
6
+ await server.connect(new StdioServerTransport());