homebridge-grizzl-e 0.1.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.
@@ -0,0 +1,28 @@
1
+ {
2
+ "pluginAlias": "GrizzlE",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "email": {
9
+ "title": "Grizzl-E Connect Email",
10
+ "type": "string",
11
+ "required": true,
12
+ "format": "email"
13
+ },
14
+ "password": {
15
+ "title": "Grizzl-E Connect Password",
16
+ "type": "string",
17
+ "required": true
18
+ },
19
+ "pollInterval": {
20
+ "title": "Poll Interval (seconds)",
21
+ "type": "integer",
22
+ "default": 30,
23
+ "minimum": 10,
24
+ "maximum": 300
25
+ }
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,51 @@
1
+ export interface GrizzlEConnector {
2
+ id: number;
3
+ type: string;
4
+ status: string;
5
+ power: number;
6
+ maxPower: number;
7
+ errorCode: string;
8
+ }
9
+ export interface GrizzlEStation {
10
+ id: string;
11
+ identity: string;
12
+ serialNumber: string;
13
+ online: boolean;
14
+ mode: string;
15
+ status: string;
16
+ errorCode: string;
17
+ connectors: GrizzlEConnector[];
18
+ currency: string;
19
+ priceKW: number;
20
+ }
21
+ export declare class GrizzlEApi {
22
+ private readonly email;
23
+ private readonly password;
24
+ private readonly log;
25
+ private token;
26
+ private tokenExpiry;
27
+ private loginInFlight;
28
+ constructor(email: string, password: string, log: {
29
+ error: (msg: string) => void;
30
+ debug: (msg: string) => void;
31
+ });
32
+ private ensureToken;
33
+ private login;
34
+ private authHeaders;
35
+ getStations(): Promise<GrizzlEStation[]>;
36
+ getStation(id: string): Promise<GrizzlEStation>;
37
+ /**
38
+ * Enable charging on a station.
39
+ *
40
+ * NOTE: Endpoint not yet confirmed via traffic capture. Update once verified
41
+ * by intercepting the Grizzl-E Connect app (e.g. with mitmproxy).
42
+ *
43
+ * Candidates to try:
44
+ * POST /client/stations/{id}/enable
45
+ * PATCH /client/stations/{id} body: { mode: 'Normal' }
46
+ * POST /client/stations/{id}/change-availability body: { type: 'Operative' }
47
+ */
48
+ setStationEnabled(id: string): Promise<void>;
49
+ setStationDisabled(id: string): Promise<void>;
50
+ }
51
+ //# sourceMappingURL=grizzlEApi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grizzlEApi.d.ts","sourceRoot":"","sources":["../src/grizzlEApi.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IAGb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AA4ED,qBAAa,UAAU;IAMnB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,aAAa,CAA8B;gBAGhC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE;QAAE,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;QAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE;YAGxE,WAAW;YAcX,KAAK;IAgBnB,OAAO,CAAC,WAAW;IAIb,WAAW,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAwBxC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IASrD;;;;;;;;;;OAUG;IACG,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5C,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAQpD"}
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GrizzlEApi = void 0;
37
+ const https = __importStar(require("https"));
38
+ const API_BASE = 'connect-api.unitedchargers.com';
39
+ // Headers captured from the Grizzl-E Connect iOS app
40
+ const APP_HEADERS = {
41
+ 'Content-Type': 'application/json',
42
+ 'User-Agent': 'GrizzlEConnect/115 CFNetwork/3826.500.131 Darwin/24.5.0',
43
+ 'x-app-client': 'Apple, iPad14,3, iPadOS 18.5',
44
+ 'x-app-version': 'v0.9.2 (115)',
45
+ 'x-application-name': 'Grizzl-E Connect',
46
+ };
47
+ function parseJwtExpiry(token) {
48
+ try {
49
+ const payload = token.split('.')[1];
50
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
51
+ return decoded.exp * 1000; // convert to ms
52
+ }
53
+ catch {
54
+ return 0;
55
+ }
56
+ }
57
+ function request(method, path, headers, body) {
58
+ return new Promise((resolve, reject) => {
59
+ const bodyStr = body ? JSON.stringify(body) : undefined;
60
+ const reqHeaders = { ...headers };
61
+ if (bodyStr) {
62
+ reqHeaders['Content-Length'] = Buffer.byteLength(bodyStr).toString();
63
+ }
64
+ const req = https.request({
65
+ hostname: API_BASE,
66
+ path,
67
+ method,
68
+ headers: reqHeaders,
69
+ }, (res) => {
70
+ const chunks = [];
71
+ res.on('data', (chunk) => chunks.push(chunk));
72
+ res.on('end', () => {
73
+ const raw = Buffer.concat(chunks).toString('utf8');
74
+ if (res.statusCode && res.statusCode >= 400) {
75
+ reject(new Error(`HTTP ${res.statusCode}: ${raw}`));
76
+ return;
77
+ }
78
+ if (!raw) {
79
+ resolve(undefined);
80
+ return;
81
+ }
82
+ try {
83
+ resolve(JSON.parse(raw));
84
+ }
85
+ catch {
86
+ reject(new Error(`Failed to parse response: ${raw}`));
87
+ }
88
+ });
89
+ });
90
+ req.on('error', reject);
91
+ if (bodyStr) {
92
+ req.write(bodyStr);
93
+ }
94
+ req.end();
95
+ });
96
+ }
97
+ class GrizzlEApi {
98
+ constructor(email, password, log) {
99
+ this.email = email;
100
+ this.password = password;
101
+ this.log = log;
102
+ this.token = null;
103
+ this.tokenExpiry = 0;
104
+ this.loginInFlight = null;
105
+ }
106
+ async ensureToken() {
107
+ if (this.token && Date.now() < this.tokenExpiry - 30000) {
108
+ return;
109
+ }
110
+ // Deduplicate concurrent login attempts
111
+ if (this.loginInFlight) {
112
+ return this.loginInFlight;
113
+ }
114
+ this.loginInFlight = this.login().finally(() => {
115
+ this.loginInFlight = null;
116
+ });
117
+ return this.loginInFlight;
118
+ }
119
+ async login() {
120
+ this.log.debug('Logging in to Grizzl-E Connect API');
121
+ const raw = await request('POST', '/client/auth/login', APP_HEADERS, {
122
+ emailOrPhone: this.email,
123
+ password: this.password,
124
+ });
125
+ this.log.debug(`Login raw response: ${JSON.stringify(raw)}`);
126
+ const resp = raw;
127
+ if (!resp.token) {
128
+ throw new Error(`Login did not return a token. Response: ${JSON.stringify(raw)}`);
129
+ }
130
+ this.token = resp.token;
131
+ this.tokenExpiry = parseJwtExpiry(resp.token);
132
+ this.log.debug(`Logged in as ${resp.user.firstName} ${resp.user.lastName}`);
133
+ }
134
+ authHeaders() {
135
+ return { ...APP_HEADERS, Authorization: `Bearer ${this.token}` };
136
+ }
137
+ async getStations() {
138
+ await this.ensureToken();
139
+ const raw = await request('GET', '/client/stations?includeShared=true&getLegacySchedulePrices=true', this.authHeaders());
140
+ this.log.debug(`getStations raw response: ${JSON.stringify(raw)}`);
141
+ let list;
142
+ if (Array.isArray(raw)) {
143
+ list = raw;
144
+ }
145
+ else {
146
+ // Some APIs wrap the array: { data: [...] } or { stations: [...] } or { items: [...] }
147
+ const wrapped = raw;
148
+ const inner = wrapped['data'] ?? wrapped['stations'] ?? wrapped['items'];
149
+ if (Array.isArray(inner)) {
150
+ list = inner;
151
+ }
152
+ else {
153
+ throw new Error(`Unexpected getStations response shape: ${JSON.stringify(raw)}`);
154
+ }
155
+ }
156
+ // Ensure connectors is always an array
157
+ for (const s of list) {
158
+ s.connectors = s.connectors ?? [];
159
+ }
160
+ return list;
161
+ }
162
+ async getStation(id) {
163
+ await this.ensureToken();
164
+ const raw = await request('GET', `/client/stations/${id}?getLegacySchedulePrices=true`, this.authHeaders());
165
+ this.log.debug(`getStation(${id}) raw response: ${JSON.stringify(raw)}`);
166
+ const station = raw;
167
+ station.connectors = station.connectors ?? [];
168
+ return station;
169
+ }
170
+ /**
171
+ * Enable charging on a station.
172
+ *
173
+ * NOTE: Endpoint not yet confirmed via traffic capture. Update once verified
174
+ * by intercepting the Grizzl-E Connect app (e.g. with mitmproxy).
175
+ *
176
+ * Candidates to try:
177
+ * POST /client/stations/{id}/enable
178
+ * PATCH /client/stations/{id} body: { mode: 'Normal' }
179
+ * POST /client/stations/{id}/change-availability body: { type: 'Operative' }
180
+ */
181
+ async setStationEnabled(id) {
182
+ await this.ensureToken();
183
+ const raw = await request('POST', `/client/stations/${id}/mode`, this.authHeaders(), {
184
+ mode: 'Active',
185
+ connectorId: 1,
186
+ });
187
+ this.log.debug(`setStationEnabled(${id}) response: ${JSON.stringify(raw)}`);
188
+ }
189
+ async setStationDisabled(id) {
190
+ await this.ensureToken();
191
+ const raw = await request('POST', `/client/stations/${id}/mode`, this.authHeaders(), {
192
+ mode: 'Inactive',
193
+ connectorId: 1,
194
+ });
195
+ this.log.debug(`setStationDisabled(${id}) response: ${JSON.stringify(raw)}`);
196
+ }
197
+ }
198
+ exports.GrizzlEApi = GrizzlEApi;
199
+ //# sourceMappingURL=grizzlEApi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grizzlEApi.js","sourceRoot":"","sources":["../src/grizzlEApi.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { API } from 'homebridge';
2
+ declare const _default: (api: API) => void;
3
+ export = _default;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;yBAIvB,KAAK,GAAG,KAAG,IAAI;AAAzB,kBAEE"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ const platform_1 = require("./platform");
3
+ module.exports = (api) => {
4
+ api.registerPlatform(platform_1.PLATFORM_NAME, platform_1.GrizzlEPlatform);
5
+ };
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,yCAA4D;AAG5D,iBAAS,CAAC,GAAQ,EAAQ,EAAE;IAC1B,GAAG,CAAC,gBAAgB,CAAC,wBAAa,EAAE,0BAAe,CAAC,CAAC;AACvD,CAAC,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge';
2
+ export declare const PLATFORM_NAME = "GrizzlE";
3
+ export declare const PLUGIN_NAME = "homebridge-grizzl-e";
4
+ export declare class GrizzlEPlatform implements DynamicPlatformPlugin {
5
+ readonly log: Logger;
6
+ readonly config: PlatformConfig;
7
+ readonly homebridgeApi: API;
8
+ private readonly grizzlApi;
9
+ private readonly cachedAccessories;
10
+ private readonly chargerAccessories;
11
+ private readonly pollInterval;
12
+ constructor(log: Logger, config: PlatformConfig, homebridgeApi: API);
13
+ configureAccessory(accessory: PlatformAccessory): void;
14
+ private discoverDevices;
15
+ private pollStations;
16
+ }
17
+ //# sourceMappingURL=platform.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,GAAG,EACH,qBAAqB,EACrB,MAAM,EACN,iBAAiB,EACjB,cAAc,EACf,MAAM,YAAY,CAAC;AAIpB,eAAO,MAAM,aAAa,YAAY,CAAC;AACvC,eAAO,MAAM,WAAW,wBAAwB,CAAC;AAEjD,qBAAa,eAAgB,YAAW,qBAAqB;aAOzC,GAAG,EAAE,MAAM;aACX,MAAM,EAAE,cAAc;aACtB,aAAa,EAAE,GAAG;IARpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAa;IACvC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA2B;IAC7D,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA8C;IACjF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;gBAGpB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,cAAc,EACtB,aAAa,EAAE,GAAG;IAepC,kBAAkB,CAAC,SAAS,EAAE,iBAAiB,GAAG,IAAI;YAKxC,eAAe;YAoDf,YAAY;CAa3B"}
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GrizzlEPlatform = exports.PLUGIN_NAME = exports.PLATFORM_NAME = void 0;
4
+ const grizzlEApi_1 = require("./grizzlEApi");
5
+ const platformAccessory_1 = require("./platformAccessory");
6
+ exports.PLATFORM_NAME = 'GrizzlE';
7
+ exports.PLUGIN_NAME = 'homebridge-grizzl-e';
8
+ class GrizzlEPlatform {
9
+ constructor(log, config, homebridgeApi) {
10
+ this.log = log;
11
+ this.config = config;
12
+ this.homebridgeApi = homebridgeApi;
13
+ this.cachedAccessories = [];
14
+ this.chargerAccessories = new Map();
15
+ this.pollInterval = config['pollInterval'] ?? 30;
16
+ this.grizzlApi = new grizzlEApi_1.GrizzlEApi(config['email'], config['password'], log);
17
+ this.homebridgeApi.on('didFinishLaunching', () => {
18
+ this.discoverDevices();
19
+ });
20
+ }
21
+ configureAccessory(accessory) {
22
+ this.log.debug(`Restoring cached accessory: ${accessory.displayName}`);
23
+ this.cachedAccessories.push(accessory);
24
+ }
25
+ async discoverDevices() {
26
+ let stations;
27
+ try {
28
+ stations = await this.grizzlApi.getStations();
29
+ }
30
+ catch (err) {
31
+ this.log.error(`Failed to fetch Grizzl-E stations: ${err}`);
32
+ return;
33
+ }
34
+ this.log.info(`Found ${stations.length} Grizzl-E station(s)`);
35
+ const discoveredUUIDs = new Set();
36
+ for (const station of stations) {
37
+ const uuid = this.homebridgeApi.hap.uuid.generate(station.id);
38
+ discoveredUUIDs.add(uuid);
39
+ const existingAccessory = this.cachedAccessories.find((a) => a.UUID === uuid);
40
+ if (existingAccessory) {
41
+ this.log.info(`Restoring charger: ${existingAccessory.displayName} (${station.serialNumber})`);
42
+ existingAccessory.context['station'] = station;
43
+ this.homebridgeApi.updatePlatformAccessories([existingAccessory]);
44
+ this.chargerAccessories.set(uuid, new platformAccessory_1.GrizzlEChargerAccessory(this.homebridgeApi.hap, this.log, existingAccessory, this.grizzlApi, station));
45
+ }
46
+ else {
47
+ const name = station.identity || station.serialNumber || station.id;
48
+ this.log.info(`Adding new charger: ${name} (${station.serialNumber})`);
49
+ const accessory = new this.homebridgeApi.platformAccessory(name, uuid);
50
+ accessory.context['station'] = station;
51
+ this.chargerAccessories.set(uuid, new platformAccessory_1.GrizzlEChargerAccessory(this.homebridgeApi.hap, this.log, accessory, this.grizzlApi, station));
52
+ this.homebridgeApi.registerPlatformAccessories(exports.PLUGIN_NAME, exports.PLATFORM_NAME, [accessory]);
53
+ }
54
+ }
55
+ // Remove accessories no longer in the account
56
+ const staleAccessories = this.cachedAccessories.filter((a) => !discoveredUUIDs.has(a.UUID));
57
+ if (staleAccessories.length > 0) {
58
+ this.log.info(`Removing ${staleAccessories.length} stale accessory(s)`);
59
+ for (const stale of staleAccessories) {
60
+ this.chargerAccessories.delete(stale.UUID);
61
+ }
62
+ this.homebridgeApi.unregisterPlatformAccessories(exports.PLUGIN_NAME, exports.PLATFORM_NAME, staleAccessories);
63
+ }
64
+ // Single poll loop for all chargers using getStations()
65
+ setInterval(() => this.pollStations(), this.pollInterval * 1000);
66
+ }
67
+ async pollStations() {
68
+ let stations;
69
+ try {
70
+ stations = await this.grizzlApi.getStations();
71
+ }
72
+ catch (err) {
73
+ this.log.error(`Poll failed: ${err}`);
74
+ return;
75
+ }
76
+ for (const station of stations) {
77
+ const uuid = this.homebridgeApi.hap.uuid.generate(station.id);
78
+ this.chargerAccessories.get(uuid)?.updateStation(station);
79
+ }
80
+ }
81
+ }
82
+ exports.GrizzlEPlatform = GrizzlEPlatform;
83
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import { PlatformAccessory, Logger, HAP } from 'homebridge';
2
+ import { GrizzlEApi, GrizzlEStation } from './grizzlEApi';
3
+ export declare class GrizzlEChargerAccessory {
4
+ private readonly hap;
5
+ private readonly log;
6
+ private readonly accessory;
7
+ private readonly api;
8
+ private readonly service;
9
+ private station;
10
+ constructor(hap: HAP, log: Logger, accessory: PlatformAccessory, api: GrizzlEApi, initialStation: GrizzlEStation);
11
+ private isEnabled;
12
+ private isCharging;
13
+ private assertOnline;
14
+ private getOn;
15
+ private setOn;
16
+ private getOutletInUse;
17
+ updateStation(station: GrizzlEStation): void;
18
+ }
19
+ //# sourceMappingURL=platformAccessory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platformAccessory.d.ts","sourceRoot":"","sources":["../src/platformAccessory.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,iBAAiB,EAEjB,MAAM,EACN,GAAG,EACJ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAM1D,qBAAa,uBAAuB;IAKhC,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,OAAO,CAAiB;gBAGb,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,iBAAiB,EAC5B,GAAG,EAAE,UAAU,EAChC,cAAc,EAAE,cAAc;IA4BhC,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,KAAK;YAKC,KAAK;IAkBnB,OAAO,CAAC,cAAc;IAKtB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;CA4B7C"}
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GrizzlEChargerAccessory = void 0;
4
+ // Station/connector mode values used by the Grizzl-E Connect API
5
+ const DISABLED_MODES = new Set(['Inactive', 'SuspendedEVSE', 'Unavailable']);
6
+ const CHARGING_STATUS = 'Charging';
7
+ class GrizzlEChargerAccessory {
8
+ constructor(hap, log, accessory, api, initialStation) {
9
+ this.hap = hap;
10
+ this.log = log;
11
+ this.accessory = accessory;
12
+ this.api = api;
13
+ this.station = initialStation;
14
+ // Set accessory information
15
+ const infoService = this.accessory.getService(this.hap.Service.AccessoryInformation);
16
+ infoService
17
+ .setCharacteristic(this.hap.Characteristic.Manufacturer, 'United Chargers / Grizzl-E')
18
+ .setCharacteristic(this.hap.Characteristic.Model, 'Connect')
19
+ .setCharacteristic(this.hap.Characteristic.SerialNumber, initialStation.serialNumber || initialStation.id);
20
+ // Use Outlet service — On = charging enabled, OutletInUse = actively charging
21
+ this.service =
22
+ this.accessory.getService(this.hap.Service.Outlet) ||
23
+ this.accessory.addService(this.hap.Service.Outlet);
24
+ this.service.setCharacteristic(this.hap.Characteristic.Name, accessory.displayName);
25
+ this.service
26
+ .getCharacteristic(this.hap.Characteristic.On)
27
+ .onGet(this.getOn.bind(this))
28
+ .onSet(this.setOn.bind(this));
29
+ this.service
30
+ .getCharacteristic(this.hap.Characteristic.OutletInUse)
31
+ .onGet(this.getOutletInUse.bind(this));
32
+ }
33
+ isEnabled(station) {
34
+ // Station-level mode takes precedence (set via POST /mode)
35
+ if (DISABLED_MODES.has(station.mode)) {
36
+ return false;
37
+ }
38
+ if (station.connectors.length > 0) {
39
+ return !DISABLED_MODES.has(station.connectors[0].status);
40
+ }
41
+ return !DISABLED_MODES.has(station.status);
42
+ }
43
+ isCharging(station) {
44
+ if (station.connectors.length > 0) {
45
+ return station.connectors.some((c) => c.status === CHARGING_STATUS);
46
+ }
47
+ return station.status === CHARGING_STATUS;
48
+ }
49
+ assertOnline() {
50
+ if (this.station.online === false) {
51
+ throw new this.hap.HapStatusError(-70402 /* this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
52
+ }
53
+ }
54
+ getOn() {
55
+ this.assertOnline();
56
+ return this.isEnabled(this.station);
57
+ }
58
+ async setOn(value) {
59
+ this.assertOnline();
60
+ const enabled = value;
61
+ this.log.info(`[${this.accessory.displayName}] Setting charging ${enabled ? 'enabled' : 'disabled'}`);
62
+ try {
63
+ if (enabled) {
64
+ await this.api.setStationEnabled(this.station.id);
65
+ }
66
+ else {
67
+ await this.api.setStationDisabled(this.station.id);
68
+ }
69
+ // Optimistically update local mode so state reflects immediately without waiting for next poll
70
+ this.station = { ...this.station, mode: enabled ? 'Active' : 'Inactive' };
71
+ }
72
+ catch (err) {
73
+ this.log.error(`[${this.accessory.displayName}] Failed to set charging state: ${err}`);
74
+ throw new this.hap.HapStatusError(-70402 /* this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
75
+ }
76
+ }
77
+ getOutletInUse() {
78
+ this.assertOnline();
79
+ return this.isCharging(this.station);
80
+ }
81
+ updateStation(station) {
82
+ const wasOnline = this.station.online;
83
+ const wasEnabled = this.isEnabled(this.station);
84
+ const wasCharging = this.isCharging(this.station);
85
+ this.station = station;
86
+ if (station.online !== wasOnline) {
87
+ this.log.info(`[${this.accessory.displayName}] ${station.online ? 'Online' : 'Offline'}`);
88
+ // When going offline, Home will show "No Response" on next get via assertOnline()
89
+ if (!station.online) {
90
+ return;
91
+ }
92
+ }
93
+ if (!station.online) {
94
+ return;
95
+ }
96
+ const nowEnabled = this.isEnabled(station);
97
+ const nowCharging = this.isCharging(station);
98
+ if (nowEnabled !== wasEnabled) {
99
+ this.service.updateCharacteristic(this.hap.Characteristic.On, nowEnabled);
100
+ }
101
+ if (nowCharging !== wasCharging) {
102
+ this.service.updateCharacteristic(this.hap.Characteristic.OutletInUse, nowCharging);
103
+ }
104
+ }
105
+ }
106
+ exports.GrizzlEChargerAccessory = GrizzlEChargerAccessory;
107
+ //# sourceMappingURL=platformAccessory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platformAccessory.js","sourceRoot":"","sources":["../src/platformAccessory.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "homebridge-grizzl-e",
3
+ "version": "0.1.0",
4
+ "description": "Homebridge plugin for Grizzl-E Connect EV chargers",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "watch": "tsc -w",
10
+ "prepublishOnly": "npm run build"
11
+ },
12
+ "homebridge": {
13
+ "displayName": "Grizzl-E Connect",
14
+ "pluginType": "platform",
15
+ "singular": true
16
+ },
17
+ "keywords": [
18
+ "homebridge-plugin",
19
+ "grizzl-e",
20
+ "ev",
21
+ "charger",
22
+ "homekit"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18.0.0",
26
+ "homebridge": ">=1.6.0"
27
+ },
28
+ "peerDependencies": {
29
+ "homebridge": ">=1.6.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.0.0",
33
+ "homebridge": "^1.8.0",
34
+ "typescript": "^5.0.0"
35
+ }
36
+ }
@@ -0,0 +1,219 @@
1
+ import * as https from 'https';
2
+ import * as http from 'http';
3
+
4
+ const API_BASE = 'connect-api.unitedchargers.com';
5
+
6
+ // Headers captured from the Grizzl-E Connect iOS app
7
+ const APP_HEADERS: Record<string, string> = {
8
+ 'Content-Type': 'application/json',
9
+ 'User-Agent': 'GrizzlEConnect/115 CFNetwork/3826.500.131 Darwin/24.5.0',
10
+ 'x-app-client': 'Apple, iPad14,3, iPadOS 18.5',
11
+ 'x-app-version': 'v0.9.2 (115)',
12
+ 'x-application-name': 'Grizzl-E Connect',
13
+ };
14
+
15
+ export interface GrizzlEConnector {
16
+ id: number;
17
+ type: string;
18
+ // OCPP standard statuses: Available, Preparing, Charging, SuspendedEVSE,
19
+ // SuspendedEV, Finishing, Reserved, Unavailable, Faulted
20
+ status: string;
21
+ power: number;
22
+ maxPower: number;
23
+ errorCode: string;
24
+ }
25
+
26
+ export interface GrizzlEStation {
27
+ id: string;
28
+ identity: string;
29
+ serialNumber: string;
30
+ online: boolean;
31
+ mode: string;
32
+ status: string;
33
+ errorCode: string;
34
+ connectors: GrizzlEConnector[];
35
+ currency: string;
36
+ priceKW: number;
37
+ }
38
+
39
+ interface LoginResponse {
40
+ token: string;
41
+ user: {
42
+ id: string;
43
+ firstName: string;
44
+ lastName: string;
45
+ email: string;
46
+ };
47
+ }
48
+
49
+ interface JwtPayload {
50
+ exp: number;
51
+ }
52
+
53
+ function parseJwtExpiry(token: string): number {
54
+ try {
55
+ const payload = token.split('.')[1];
56
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as JwtPayload;
57
+ return decoded.exp * 1000; // convert to ms
58
+ } catch {
59
+ return 0;
60
+ }
61
+ }
62
+
63
+ function request<T>(
64
+ method: string,
65
+ path: string,
66
+ headers: Record<string, string>,
67
+ body?: unknown,
68
+ ): Promise<T> {
69
+ return new Promise((resolve, reject) => {
70
+ const bodyStr = body ? JSON.stringify(body) : undefined;
71
+ const reqHeaders: Record<string, string> = { ...headers };
72
+ if (bodyStr) {
73
+ reqHeaders['Content-Length'] = Buffer.byteLength(bodyStr).toString();
74
+ }
75
+
76
+ const req = https.request(
77
+ {
78
+ hostname: API_BASE,
79
+ path,
80
+ method,
81
+ headers: reqHeaders,
82
+ },
83
+ (res) => {
84
+ const chunks: Buffer[] = [];
85
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
86
+ res.on('end', () => {
87
+ const raw = Buffer.concat(chunks).toString('utf8');
88
+ if (res.statusCode && res.statusCode >= 400) {
89
+ reject(new Error(`HTTP ${res.statusCode}: ${raw}`));
90
+ return;
91
+ }
92
+ if (!raw) {
93
+ resolve(undefined as T);
94
+ return;
95
+ }
96
+ try {
97
+ resolve(JSON.parse(raw) as T);
98
+ } catch {
99
+ reject(new Error(`Failed to parse response: ${raw}`));
100
+ }
101
+ });
102
+ },
103
+ );
104
+
105
+ req.on('error', reject);
106
+ if (bodyStr) {
107
+ req.write(bodyStr);
108
+ }
109
+ req.end();
110
+ });
111
+ }
112
+
113
+ export class GrizzlEApi {
114
+ private token: string | null = null;
115
+ private tokenExpiry = 0;
116
+ private loginInFlight: Promise<void> | null = null;
117
+
118
+ constructor(
119
+ private readonly email: string,
120
+ private readonly password: string,
121
+ private readonly log: { error: (msg: string) => void; debug: (msg: string) => void },
122
+ ) {}
123
+
124
+ private async ensureToken(): Promise<void> {
125
+ if (this.token && Date.now() < this.tokenExpiry - 30_000) {
126
+ return;
127
+ }
128
+ // Deduplicate concurrent login attempts
129
+ if (this.loginInFlight) {
130
+ return this.loginInFlight;
131
+ }
132
+ this.loginInFlight = this.login().finally(() => {
133
+ this.loginInFlight = null;
134
+ });
135
+ return this.loginInFlight;
136
+ }
137
+
138
+ private async login(): Promise<void> {
139
+ this.log.debug('Logging in to Grizzl-E Connect API');
140
+ const raw = await request<unknown>('POST', '/client/auth/login', APP_HEADERS, {
141
+ emailOrPhone: this.email,
142
+ password: this.password,
143
+ });
144
+ this.log.debug(`Login raw response: ${JSON.stringify(raw)}`);
145
+ const resp = raw as LoginResponse;
146
+ if (!resp.token) {
147
+ throw new Error(`Login did not return a token. Response: ${JSON.stringify(raw)}`);
148
+ }
149
+ this.token = resp.token;
150
+ this.tokenExpiry = parseJwtExpiry(resp.token);
151
+ this.log.debug(`Logged in as ${resp.user.firstName} ${resp.user.lastName}`);
152
+ }
153
+
154
+ private authHeaders(): Record<string, string> {
155
+ return { ...APP_HEADERS, Authorization: `Bearer ${this.token}` };
156
+ }
157
+
158
+ async getStations(): Promise<GrizzlEStation[]> {
159
+ await this.ensureToken();
160
+ const raw = await request<unknown>('GET', '/client/stations?includeShared=true&getLegacySchedulePrices=true', this.authHeaders());
161
+ this.log.debug(`getStations raw response: ${JSON.stringify(raw)}`);
162
+ let list: GrizzlEStation[];
163
+ if (Array.isArray(raw)) {
164
+ list = raw as GrizzlEStation[];
165
+ } else {
166
+ // Some APIs wrap the array: { data: [...] } or { stations: [...] } or { items: [...] }
167
+ const wrapped = raw as Record<string, unknown>;
168
+ const inner = wrapped['data'] ?? wrapped['stations'] ?? wrapped['items'];
169
+ if (Array.isArray(inner)) {
170
+ list = inner as GrizzlEStation[];
171
+ } else {
172
+ throw new Error(`Unexpected getStations response shape: ${JSON.stringify(raw)}`);
173
+ }
174
+ }
175
+ // Ensure connectors is always an array
176
+ for (const s of list) {
177
+ s.connectors = s.connectors ?? [];
178
+ }
179
+ return list;
180
+ }
181
+
182
+ async getStation(id: string): Promise<GrizzlEStation> {
183
+ await this.ensureToken();
184
+ const raw = await request<unknown>('GET', `/client/stations/${id}?getLegacySchedulePrices=true`, this.authHeaders());
185
+ this.log.debug(`getStation(${id}) raw response: ${JSON.stringify(raw)}`);
186
+ const station = raw as GrizzlEStation;
187
+ station.connectors = station.connectors ?? [];
188
+ return station;
189
+ }
190
+
191
+ /**
192
+ * Enable charging on a station.
193
+ *
194
+ * NOTE: Endpoint not yet confirmed via traffic capture. Update once verified
195
+ * by intercepting the Grizzl-E Connect app (e.g. with mitmproxy).
196
+ *
197
+ * Candidates to try:
198
+ * POST /client/stations/{id}/enable
199
+ * PATCH /client/stations/{id} body: { mode: 'Normal' }
200
+ * POST /client/stations/{id}/change-availability body: { type: 'Operative' }
201
+ */
202
+ async setStationEnabled(id: string): Promise<void> {
203
+ await this.ensureToken();
204
+ const raw = await request<unknown>('POST', `/client/stations/${id}/mode`, this.authHeaders(), {
205
+ mode: 'Active',
206
+ connectorId: 1,
207
+ });
208
+ this.log.debug(`setStationEnabled(${id}) response: ${JSON.stringify(raw)}`);
209
+ }
210
+
211
+ async setStationDisabled(id: string): Promise<void> {
212
+ await this.ensureToken();
213
+ const raw = await request<unknown>('POST', `/client/stations/${id}/mode`, this.authHeaders(), {
214
+ mode: 'Inactive',
215
+ connectorId: 1,
216
+ });
217
+ this.log.debug(`setStationDisabled(${id}) response: ${JSON.stringify(raw)}`);
218
+ }
219
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { API } from 'homebridge';
2
+ import { GrizzlEPlatform, PLATFORM_NAME } from './platform';
3
+
4
+ // homebridge requires CommonJS-style export
5
+ export = (api: API): void => {
6
+ api.registerPlatform(PLATFORM_NAME, GrizzlEPlatform);
7
+ };
@@ -0,0 +1,108 @@
1
+ import {
2
+ API,
3
+ DynamicPlatformPlugin,
4
+ Logger,
5
+ PlatformAccessory,
6
+ PlatformConfig,
7
+ } from 'homebridge';
8
+ import { GrizzlEApi, GrizzlEStation } from './grizzlEApi';
9
+ import { GrizzlEChargerAccessory } from './platformAccessory';
10
+
11
+ export const PLATFORM_NAME = 'GrizzlE';
12
+ export const PLUGIN_NAME = 'homebridge-grizzl-e';
13
+
14
+ export class GrizzlEPlatform implements DynamicPlatformPlugin {
15
+ private readonly grizzlApi: GrizzlEApi;
16
+ private readonly cachedAccessories: PlatformAccessory[] = [];
17
+ private readonly chargerAccessories = new Map<string, GrizzlEChargerAccessory>();
18
+ private readonly pollInterval: number;
19
+
20
+ constructor(
21
+ public readonly log: Logger,
22
+ public readonly config: PlatformConfig,
23
+ public readonly homebridgeApi: API,
24
+ ) {
25
+ this.pollInterval = (config['pollInterval'] as number | undefined) ?? 30;
26
+
27
+ this.grizzlApi = new GrizzlEApi(
28
+ config['email'] as string,
29
+ config['password'] as string,
30
+ log,
31
+ );
32
+
33
+ this.homebridgeApi.on('didFinishLaunching', () => {
34
+ this.discoverDevices();
35
+ });
36
+ }
37
+
38
+ configureAccessory(accessory: PlatformAccessory): void {
39
+ this.log.debug(`Restoring cached accessory: ${accessory.displayName}`);
40
+ this.cachedAccessories.push(accessory);
41
+ }
42
+
43
+ private async discoverDevices(): Promise<void> {
44
+ let stations: GrizzlEStation[];
45
+ try {
46
+ stations = await this.grizzlApi.getStations();
47
+ } catch (err) {
48
+ this.log.error(`Failed to fetch Grizzl-E stations: ${err}`);
49
+ return;
50
+ }
51
+
52
+ this.log.info(`Found ${stations.length} Grizzl-E station(s)`);
53
+
54
+ const discoveredUUIDs = new Set<string>();
55
+
56
+ for (const station of stations) {
57
+ const uuid = this.homebridgeApi.hap.uuid.generate(station.id);
58
+ discoveredUUIDs.add(uuid);
59
+
60
+ const existingAccessory = this.cachedAccessories.find((a) => a.UUID === uuid);
61
+
62
+ if (existingAccessory) {
63
+ this.log.info(`Restoring charger: ${existingAccessory.displayName} (${station.serialNumber})`);
64
+ existingAccessory.context['station'] = station;
65
+ this.homebridgeApi.updatePlatformAccessories([existingAccessory]);
66
+ this.chargerAccessories.set(uuid, new GrizzlEChargerAccessory(
67
+ this.homebridgeApi.hap, this.log, existingAccessory, this.grizzlApi, station,
68
+ ));
69
+ } else {
70
+ const name = station.identity || station.serialNumber || station.id;
71
+ this.log.info(`Adding new charger: ${name} (${station.serialNumber})`);
72
+ const accessory = new this.homebridgeApi.platformAccessory(name, uuid);
73
+ accessory.context['station'] = station;
74
+ this.chargerAccessories.set(uuid, new GrizzlEChargerAccessory(
75
+ this.homebridgeApi.hap, this.log, accessory, this.grizzlApi, station,
76
+ ));
77
+ this.homebridgeApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
78
+ }
79
+ }
80
+
81
+ // Remove accessories no longer in the account
82
+ const staleAccessories = this.cachedAccessories.filter((a) => !discoveredUUIDs.has(a.UUID));
83
+ if (staleAccessories.length > 0) {
84
+ this.log.info(`Removing ${staleAccessories.length} stale accessory(s)`);
85
+ for (const stale of staleAccessories) {
86
+ this.chargerAccessories.delete(stale.UUID);
87
+ }
88
+ this.homebridgeApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories);
89
+ }
90
+
91
+ // Single poll loop for all chargers using getStations()
92
+ setInterval(() => this.pollStations(), this.pollInterval * 1000);
93
+ }
94
+
95
+ private async pollStations(): Promise<void> {
96
+ let stations: GrizzlEStation[];
97
+ try {
98
+ stations = await this.grizzlApi.getStations();
99
+ } catch (err) {
100
+ this.log.error(`Poll failed: ${err}`);
101
+ return;
102
+ }
103
+ for (const station of stations) {
104
+ const uuid = this.homebridgeApi.hap.uuid.generate(station.id);
105
+ this.chargerAccessories.get(uuid)?.updateStation(station);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ Service,
3
+ PlatformAccessory,
4
+ CharacteristicValue,
5
+ Logger,
6
+ HAP,
7
+ } from 'homebridge';
8
+ import { GrizzlEApi, GrizzlEStation } from './grizzlEApi';
9
+
10
+ // Station/connector mode values used by the Grizzl-E Connect API
11
+ const DISABLED_MODES = new Set(['Inactive', 'SuspendedEVSE', 'Unavailable']);
12
+ const CHARGING_STATUS = 'Charging';
13
+
14
+ export class GrizzlEChargerAccessory {
15
+ private readonly service: Service;
16
+ private station: GrizzlEStation;
17
+
18
+ constructor(
19
+ private readonly hap: HAP,
20
+ private readonly log: Logger,
21
+ private readonly accessory: PlatformAccessory,
22
+ private readonly api: GrizzlEApi,
23
+ initialStation: GrizzlEStation,
24
+ ) {
25
+ this.station = initialStation;
26
+
27
+ // Set accessory information
28
+ const infoService = this.accessory.getService(this.hap.Service.AccessoryInformation)!;
29
+ infoService
30
+ .setCharacteristic(this.hap.Characteristic.Manufacturer, 'United Chargers / Grizzl-E')
31
+ .setCharacteristic(this.hap.Characteristic.Model, 'Connect')
32
+ .setCharacteristic(this.hap.Characteristic.SerialNumber, initialStation.serialNumber || initialStation.id);
33
+
34
+ // Use Outlet service — On = charging enabled, OutletInUse = actively charging
35
+ this.service =
36
+ this.accessory.getService(this.hap.Service.Outlet) ||
37
+ this.accessory.addService(this.hap.Service.Outlet);
38
+
39
+ this.service.setCharacteristic(this.hap.Characteristic.Name, accessory.displayName);
40
+
41
+ this.service
42
+ .getCharacteristic(this.hap.Characteristic.On)
43
+ .onGet(this.getOn.bind(this))
44
+ .onSet(this.setOn.bind(this));
45
+
46
+ this.service
47
+ .getCharacteristic(this.hap.Characteristic.OutletInUse)
48
+ .onGet(this.getOutletInUse.bind(this));
49
+ }
50
+
51
+ private isEnabled(station: GrizzlEStation): boolean {
52
+ // Station-level mode takes precedence (set via POST /mode)
53
+ if (DISABLED_MODES.has(station.mode)) {
54
+ return false;
55
+ }
56
+ if (station.connectors.length > 0) {
57
+ return !DISABLED_MODES.has(station.connectors[0].status);
58
+ }
59
+ return !DISABLED_MODES.has(station.status);
60
+ }
61
+
62
+ private isCharging(station: GrizzlEStation): boolean {
63
+ if (station.connectors.length > 0) {
64
+ return station.connectors.some((c) => c.status === CHARGING_STATUS);
65
+ }
66
+ return station.status === CHARGING_STATUS;
67
+ }
68
+
69
+ private assertOnline(): void {
70
+ if (this.station.online === false) {
71
+ throw new this.hap.HapStatusError(this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
72
+ }
73
+ }
74
+
75
+ private getOn(): CharacteristicValue {
76
+ this.assertOnline();
77
+ return this.isEnabled(this.station);
78
+ }
79
+
80
+ private async setOn(value: CharacteristicValue): Promise<void> {
81
+ this.assertOnline();
82
+ const enabled = value as boolean;
83
+ this.log.info(`[${this.accessory.displayName}] Setting charging ${enabled ? 'enabled' : 'disabled'}`);
84
+ try {
85
+ if (enabled) {
86
+ await this.api.setStationEnabled(this.station.id);
87
+ } else {
88
+ await this.api.setStationDisabled(this.station.id);
89
+ }
90
+ // Optimistically update local mode so state reflects immediately without waiting for next poll
91
+ this.station = { ...this.station, mode: enabled ? 'Active' : 'Inactive' };
92
+ } catch (err) {
93
+ this.log.error(`[${this.accessory.displayName}] Failed to set charging state: ${err}`);
94
+ throw new this.hap.HapStatusError(this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
95
+ }
96
+ }
97
+
98
+ private getOutletInUse(): CharacteristicValue {
99
+ this.assertOnline();
100
+ return this.isCharging(this.station);
101
+ }
102
+
103
+ updateStation(station: GrizzlEStation): void {
104
+ const wasOnline = this.station.online;
105
+ const wasEnabled = this.isEnabled(this.station);
106
+ const wasCharging = this.isCharging(this.station);
107
+ this.station = station;
108
+
109
+ if (station.online !== wasOnline) {
110
+ this.log.info(`[${this.accessory.displayName}] ${station.online ? 'Online' : 'Offline'}`);
111
+ // When going offline, Home will show "No Response" on next get via assertOnline()
112
+ if (!station.online) {
113
+ return;
114
+ }
115
+ }
116
+
117
+ if (!station.online) {
118
+ return;
119
+ }
120
+
121
+ const nowEnabled = this.isEnabled(station);
122
+ const nowCharging = this.isCharging(station);
123
+
124
+ if (nowEnabled !== wasEnabled) {
125
+ this.service.updateCharacteristic(this.hap.Characteristic.On, nowEnabled);
126
+ }
127
+ if (nowCharging !== wasCharging) {
128
+ this.service.updateCharacteristic(this.hap.Characteristic.OutletInUse, nowCharging);
129
+ }
130
+ }
131
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src"]
16
+ }