homebridge-solarman 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/config.schema.json +43 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/platform.d.ts +17 -0
- package/dist/platform.js +90 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +5 -0
- package/dist/solarSensor.d.ts +12 -0
- package/dist/solarSensor.js +59 -0
- package/dist/solarmanApi.d.ts +23 -0
- package/dist/solarmanApi.js +116 -0
- package/fix_data.json +1 -0
- package/fix_platform.js +8 -0
- package/generate_base.js +181 -0
- package/generate_main.js +193 -0
- package/package.json +36 -0
- package/plat_fix.json +1 -0
- package/src/index.ts +7 -0
- package/src/platform.ts +102 -0
- package/src/settings.ts +2 -0
- package/src/solarSensor.ts +81 -0
- package/src/solarmanApi.ts +92 -0
- package/tsconfig.json +21 -0
- package/write_plat.js +4 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "SolarmanMonitor",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Homebridge plugin for SOLARMAN solar inverter monitoring.",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"title": "Name",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"required": true,
|
|
13
|
+
"default": "Solarman"
|
|
14
|
+
},
|
|
15
|
+
"email": {
|
|
16
|
+
"title": "Email",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"required": true,
|
|
19
|
+
"description": "Your SOLARMAN account email"
|
|
20
|
+
},
|
|
21
|
+
"password": {
|
|
22
|
+
"title": "Password",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"required": true,
|
|
25
|
+
"description": "Your SOLARMAN account password"
|
|
26
|
+
},
|
|
27
|
+
"plantId": {
|
|
28
|
+
"title": "Plant ID",
|
|
29
|
+
"type": "integer",
|
|
30
|
+
"required": false,
|
|
31
|
+
"description": "Plant/Station ID (auto-detected if omitted)"
|
|
32
|
+
},
|
|
33
|
+
"pollingInterval": {
|
|
34
|
+
"title": "Polling Interval (seconds)",
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"required": false,
|
|
37
|
+
"default": 60,
|
|
38
|
+
"minimum": 30,
|
|
39
|
+
"maximum": 600
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const settings_1 = require("./settings");
|
|
4
|
+
const platform_1 = require("./platform");
|
|
5
|
+
exports.default = (api) => {
|
|
6
|
+
api.registerPlatform(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, platform_1.SolarmanPlatform);
|
|
7
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge';
|
|
2
|
+
import { SolarmanApi } from './solarmanApi';
|
|
3
|
+
export declare class SolarmanPlatform implements DynamicPlatformPlugin {
|
|
4
|
+
readonly log: Logger;
|
|
5
|
+
readonly config: PlatformConfig;
|
|
6
|
+
readonly api: API;
|
|
7
|
+
readonly Service: typeof Service;
|
|
8
|
+
readonly Characteristic: typeof Characteristic;
|
|
9
|
+
solarApi: SolarmanApi;
|
|
10
|
+
private pollingInterval;
|
|
11
|
+
private sensors;
|
|
12
|
+
constructor(log: Logger, config: PlatformConfig, api: API);
|
|
13
|
+
private readonly cachedAccessories;
|
|
14
|
+
configureAccessory(accessory: PlatformAccessory): void;
|
|
15
|
+
private onReady;
|
|
16
|
+
private poll;
|
|
17
|
+
}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SolarmanPlatform = void 0;
|
|
4
|
+
const settings_1 = require("./settings");
|
|
5
|
+
const solarmanApi_1 = require("./solarmanApi");
|
|
6
|
+
const solarSensor_1 = require("./solarSensor");
|
|
7
|
+
const SENSOR_TYPES = ['generation', 'consumption', 'battery', 'surplus'];
|
|
8
|
+
class SolarmanPlatform {
|
|
9
|
+
constructor(log, config, api) {
|
|
10
|
+
this.log = log;
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.api = api;
|
|
13
|
+
this.Service = this.api.hap.Service;
|
|
14
|
+
this.Characteristic = this.api.hap.Characteristic;
|
|
15
|
+
this.sensors = [];
|
|
16
|
+
this.cachedAccessories = [];
|
|
17
|
+
this.pollingInterval = (config.pollingInterval || 60) * 1000;
|
|
18
|
+
this.api.on('didFinishLaunching', () => this.onReady());
|
|
19
|
+
}
|
|
20
|
+
configureAccessory(accessory) {
|
|
21
|
+
this.log.info('Restoring cached accessory:', accessory.displayName);
|
|
22
|
+
this.cachedAccessories.push(accessory);
|
|
23
|
+
}
|
|
24
|
+
async onReady() {
|
|
25
|
+
if (!this.config.email || !this.config.password) {
|
|
26
|
+
this.log.error('Missing email or password in config');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this.solarApi = new solarmanApi_1.SolarmanApi(this.config.email, this.config.password, this.config.plantId, this.log);
|
|
30
|
+
try {
|
|
31
|
+
await this.solarApi.login();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
this.log.error('Failed to connect to SOLARMAN');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Register or restore 4 sensor accessories
|
|
38
|
+
const newAccessories = [];
|
|
39
|
+
for (const sType of SENSOR_TYPES) {
|
|
40
|
+
const uuid = this.api.hap.uuid.generate(settings_1.PLUGIN_NAME + '-' + sType);
|
|
41
|
+
let accessory = this.cachedAccessories.find(a => a.UUID === uuid);
|
|
42
|
+
if (!accessory) {
|
|
43
|
+
accessory = new this.api.platformAccessory('Solar ' + sType, uuid);
|
|
44
|
+
newAccessories.push(accessory);
|
|
45
|
+
this.log.info('Creating new accessory:', sType);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
this.log.info('Restoring cached accessory:', sType);
|
|
49
|
+
}
|
|
50
|
+
const sensor = new solarSensor_1.SolarSensor(this, accessory, sType);
|
|
51
|
+
this.sensors.push(sensor);
|
|
52
|
+
}
|
|
53
|
+
if (newAccessories.length > 0) {
|
|
54
|
+
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, newAccessories);
|
|
55
|
+
}
|
|
56
|
+
this.log.info('Total solar sensors: ' + this.sensors.length);
|
|
57
|
+
// Start polling
|
|
58
|
+
this.poll();
|
|
59
|
+
setInterval(() => this.poll(), this.pollingInterval);
|
|
60
|
+
}
|
|
61
|
+
async poll() {
|
|
62
|
+
try {
|
|
63
|
+
const data = await this.solarApi.getData();
|
|
64
|
+
const genKW = data.generationPower / 1000;
|
|
65
|
+
const useKW = data.usePower / 1000;
|
|
66
|
+
const surplusKW = Math.max(0, genKW - useKW);
|
|
67
|
+
for (const s of this.sensors) {
|
|
68
|
+
switch (s.sensorType) {
|
|
69
|
+
case 'generation':
|
|
70
|
+
s.updateValue(genKW);
|
|
71
|
+
break;
|
|
72
|
+
case 'consumption':
|
|
73
|
+
s.updateValue(useKW);
|
|
74
|
+
break;
|
|
75
|
+
case 'battery':
|
|
76
|
+
s.updateValue(data.batterySoc);
|
|
77
|
+
break;
|
|
78
|
+
case 'surplus':
|
|
79
|
+
s.updateValue(surplusKW);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.log.debug('Solar: gen=' + genKW.toFixed(1) + 'kW use=' + useKW.toFixed(1) + 'kW bat=' + data.batterySoc + '% surplus=' + surplusKW.toFixed(1) + 'kW');
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
this.log.error('Polling failed:', String(e));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.SolarmanPlatform = SolarmanPlatform;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PlatformAccessory } from 'homebridge';
|
|
2
|
+
import { SolarmanPlatform } from './platform';
|
|
3
|
+
export type SensorType = 'generation' | 'consumption' | 'battery' | 'surplus';
|
|
4
|
+
export declare class SolarSensor {
|
|
5
|
+
private readonly platform;
|
|
6
|
+
private readonly accessory;
|
|
7
|
+
readonly sensorType: SensorType;
|
|
8
|
+
private service;
|
|
9
|
+
private currentValue;
|
|
10
|
+
constructor(platform: SolarmanPlatform, accessory: PlatformAccessory, sensorType: SensorType);
|
|
11
|
+
updateValue(value: number): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SolarSensor = void 0;
|
|
4
|
+
const SENSOR_CONFIG = {
|
|
5
|
+
generation: { name: 'Generacion Solar', unit: 'kW' },
|
|
6
|
+
consumption: { name: 'Consumo Casa', unit: 'kW' },
|
|
7
|
+
battery: { name: 'Bateria', unit: '%' },
|
|
8
|
+
surplus: { name: 'Excedente', unit: 'kW' },
|
|
9
|
+
};
|
|
10
|
+
class SolarSensor {
|
|
11
|
+
constructor(platform, accessory, sensorType) {
|
|
12
|
+
this.platform = platform;
|
|
13
|
+
this.accessory = accessory;
|
|
14
|
+
this.sensorType = sensorType;
|
|
15
|
+
this.currentValue = 0;
|
|
16
|
+
const config = SENSOR_CONFIG[sensorType];
|
|
17
|
+
// Info service
|
|
18
|
+
this.accessory.getService(this.platform.Service.AccessoryInformation)
|
|
19
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'SOLARMAN')
|
|
20
|
+
.setCharacteristic(this.platform.Characteristic.Model, 'Solar Monitor')
|
|
21
|
+
.setCharacteristic(this.platform.Characteristic.SerialNumber, 'SM-' + sensorType);
|
|
22
|
+
// Thermostat service (dummy - for visibility in Apple Home)
|
|
23
|
+
this.service = this.accessory.getService(this.platform.Service.Thermostat)
|
|
24
|
+
|| this.accessory.addService(this.platform.Service.Thermostat);
|
|
25
|
+
this.service.setCharacteristic(this.platform.Characteristic.Name, config.name);
|
|
26
|
+
// Lock to OFF mode (read-only thermostat)
|
|
27
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState)
|
|
28
|
+
.onGet(() => 0);
|
|
29
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState)
|
|
30
|
+
.onGet(() => 0)
|
|
31
|
+
.onSet(() => {
|
|
32
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, 0);
|
|
33
|
+
})
|
|
34
|
+
.setProps({ validValues: [0] });
|
|
35
|
+
// Current temperature = our value
|
|
36
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature)
|
|
37
|
+
.onGet(() => this.currentValue)
|
|
38
|
+
.setProps({ minValue: -100, maxValue: 200 });
|
|
39
|
+
// Target temperature (locked)
|
|
40
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
|
|
41
|
+
.onGet(() => this.currentValue)
|
|
42
|
+
.onSet(() => {
|
|
43
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.currentValue);
|
|
44
|
+
})
|
|
45
|
+
.setProps({ minValue: -100, maxValue: 200 });
|
|
46
|
+
// Display in Celsius
|
|
47
|
+
this.service.getCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits)
|
|
48
|
+
.onGet(() => 0)
|
|
49
|
+
.onSet(() => {
|
|
50
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits, 0);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
updateValue(value) {
|
|
54
|
+
this.currentValue = Math.round(value * 10) / 10;
|
|
55
|
+
this.service.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.currentValue);
|
|
56
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.currentValue);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.SolarSensor = SolarSensor;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Logger } from 'homebridge';
|
|
2
|
+
export interface SolarData {
|
|
3
|
+
generationPower: number;
|
|
4
|
+
usePower: number;
|
|
5
|
+
batterySoc: number;
|
|
6
|
+
buyPower: number;
|
|
7
|
+
gridPower: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class SolarmanApi {
|
|
10
|
+
private readonly email;
|
|
11
|
+
private readonly password;
|
|
12
|
+
private readonly log;
|
|
13
|
+
private client;
|
|
14
|
+
private token;
|
|
15
|
+
private tokenExpiry;
|
|
16
|
+
private plantId;
|
|
17
|
+
constructor(email: string, password: string, plantId: number | undefined, log: Logger);
|
|
18
|
+
private hashPassword;
|
|
19
|
+
login(): Promise<void>;
|
|
20
|
+
private ensureAuth;
|
|
21
|
+
getPlantId(): Promise<number>;
|
|
22
|
+
getData(): Promise<SolarData>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.SolarmanApi = void 0;
|
|
40
|
+
const axios_1 = __importDefault(require("axios"));
|
|
41
|
+
const crypto = __importStar(require("crypto"));
|
|
42
|
+
class SolarmanApi {
|
|
43
|
+
constructor(email, password, plantId, log) {
|
|
44
|
+
this.email = email;
|
|
45
|
+
this.password = password;
|
|
46
|
+
this.log = log;
|
|
47
|
+
this.token = '';
|
|
48
|
+
this.tokenExpiry = 0;
|
|
49
|
+
this.plantId = plantId;
|
|
50
|
+
this.client = axios_1.default.create({
|
|
51
|
+
baseURL: 'https://home.solarman.cn',
|
|
52
|
+
timeout: 15000,
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
hashPassword(pwd) {
|
|
57
|
+
return crypto.createHash('sha256').update(pwd).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
async login() {
|
|
60
|
+
try {
|
|
61
|
+
const res = await this.client.post('/oauth-s/oauth/token', null, {
|
|
62
|
+
params: {
|
|
63
|
+
grant_type: 'mdc_password',
|
|
64
|
+
username: this.email,
|
|
65
|
+
clear_text_pwd: this.password,
|
|
66
|
+
password: this.hashPassword(this.password),
|
|
67
|
+
identity_type: 2,
|
|
68
|
+
client_id: 'test',
|
|
69
|
+
mdc: 'FOREIGN_1',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
this.token = res.data.access_token;
|
|
73
|
+
this.tokenExpiry = Date.now() + (res.data.expires_in || 86400) * 1000;
|
|
74
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.token;
|
|
75
|
+
this.log.info('SOLARMAN: authenticated successfully');
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
this.log.error('SOLARMAN: login failed', String(e));
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async ensureAuth() {
|
|
83
|
+
if (!this.token || Date.now() > this.tokenExpiry - 60000) {
|
|
84
|
+
await this.login();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async getPlantId() {
|
|
88
|
+
if (this.plantId)
|
|
89
|
+
return this.plantId;
|
|
90
|
+
await this.ensureAuth();
|
|
91
|
+
const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });
|
|
92
|
+
const plants = res.data?.data || [];
|
|
93
|
+
if (plants.length === 0)
|
|
94
|
+
throw new Error('No plants found');
|
|
95
|
+
this.plantId = plants[0].id;
|
|
96
|
+
this.log.info('SOLARMAN: auto-detected plant ID:', this.plantId);
|
|
97
|
+
return this.plantId;
|
|
98
|
+
}
|
|
99
|
+
async getData() {
|
|
100
|
+
await this.ensureAuth();
|
|
101
|
+
const pid = await this.getPlantId();
|
|
102
|
+
const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });
|
|
103
|
+
const plants = res.data?.data || [];
|
|
104
|
+
const plant = plants.find((p) => p.id === pid) || plants[0];
|
|
105
|
+
if (!plant)
|
|
106
|
+
throw new Error('Plant not found');
|
|
107
|
+
return {
|
|
108
|
+
generationPower: plant.generationPower || 0,
|
|
109
|
+
usePower: plant.usePower || 0,
|
|
110
|
+
batterySoc: plant.batterySoc || 0,
|
|
111
|
+
buyPower: plant.buyPower || 0,
|
|
112
|
+
gridPower: plant.gridPower || 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.SolarmanApi = SolarmanApi;
|
package/fix_data.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"oldConfigure":" configureAccessory(accessory: PlatformAccessory): void {\n // no-op for cached accessories\n }","newConfigure":" private readonly cachedAccessories: PlatformAccessory[] = [];\n\n configureAccessory(accessory: PlatformAccessory): void {\n this.log.info('Restoring cached accessory:', accessory.displayName);\n this.cachedAccessories.push(accessory);\n }","oldRegister":" // Register 4 sensor accessories\n const accessories: PlatformAccessory[] = [];\n for (const sType of SENSOR_TYPES) {\n const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + sType);\n const accessory = new this.api.platformAccessory('Solar ' + sType, uuid);\n const sensor = new SolarSensor(this, accessory, sType);\n this.sensors.push(sensor);\n accessories.push(accessory);\n }\n\n this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);\n this.log.info('Registered ' + accessories.length + ' solar sensors');","newRegister":" // Register or restore 4 sensor accessories\n const newAccessories: PlatformAccessory[] = [];\n for (const sType of SENSOR_TYPES) {\n const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + sType);\n let accessory = this.cachedAccessories.find(a => a.UUID === uuid);\n if (!accessory) {\n accessory = new this.api.platformAccessory('Solar ' + sType, uuid);\n newAccessories.push(accessory);\n this.log.info('Creating new accessory:', sType);\n } else {\n this.log.info('Restoring cached accessory:', sType);\n }\n const sensor = new SolarSensor(this, accessory, sType);\n this.sensors.push(sensor);\n }\n if (newAccessories.length > 0) {\n this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);\n }\n this.log.info('Total solar sensors: ' + this.sensors.length);"}
|
package/fix_platform.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const d = JSON.parse(fs.readFileSync("fix_data.json", "utf8"));
|
|
3
|
+
let c = fs.readFileSync("src/platform.ts", "utf8");
|
|
4
|
+
c = c.replace(d.oldConfigure, d.newConfigure);
|
|
5
|
+
c = c.replace(d.oldRegister, d.newRegister);
|
|
6
|
+
fs.writeFileSync("src/platform.ts", c, "utf8");
|
|
7
|
+
console.log("Has cachedAccessories:", c.includes("cachedAccessories"));
|
|
8
|
+
console.log("Has find UUID:", c.includes("a.UUID === uuid"));
|
package/generate_base.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const srcDir = 'src';
|
|
6
|
+
|
|
7
|
+
// ============ package.json ============
|
|
8
|
+
const pkg = {
|
|
9
|
+
name: "homebridge-solarman",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
description: "Homebridge plugin for SOLARMAN solar inverter monitoring in Apple HomeKit",
|
|
12
|
+
license: "Apache-2.0",
|
|
13
|
+
author: "trama2000",
|
|
14
|
+
repository: { type: "git", url: "https://github.com/trama2000/homebridge-solarman.git" },
|
|
15
|
+
main: "dist/index.js",
|
|
16
|
+
scripts: { build: "tsc", prepublishOnly: "npm run build" },
|
|
17
|
+
keywords: ["homebridge-plugin", "solarman", "solar", "inverter", "battery", "homekit"],
|
|
18
|
+
engines: { homebridge: ">=1.6.0", node: ">=18.0.0" },
|
|
19
|
+
dependencies: { axios: "^1.6.0" },
|
|
20
|
+
devDependencies: { "@types/node": "^20.0.0", homebridge: "^1.8.0", typescript: "^5.0.0" }
|
|
21
|
+
};
|
|
22
|
+
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
|
23
|
+
console.log('wrote package.json');
|
|
24
|
+
|
|
25
|
+
// ============ tsconfig.json ============
|
|
26
|
+
const tsconfig = {
|
|
27
|
+
compilerOptions: {
|
|
28
|
+
target: "ES2020", module: "commonjs", lib: ["ES2020"],
|
|
29
|
+
declaration: true, strict: true, noEmit: false, outDir: "./dist",
|
|
30
|
+
rootDir: "./src", esModuleInterop: true, forceConsistentCasingInFileNames: true,
|
|
31
|
+
skipLibCheck: true, resolveJsonModule: true
|
|
32
|
+
},
|
|
33
|
+
include: ["src/"]
|
|
34
|
+
};
|
|
35
|
+
fs.writeFileSync('tsconfig.json', JSON.stringify(tsconfig, null, 2));
|
|
36
|
+
console.log('wrote tsconfig.json');
|
|
37
|
+
|
|
38
|
+
// ============ .gitignore ============
|
|
39
|
+
fs.writeFileSync('.gitignore', 'dist/\nnode_modules/\n.DS_Store\n*.tgz\n');
|
|
40
|
+
console.log('wrote .gitignore');
|
|
41
|
+
|
|
42
|
+
// ============ config.schema.json ============
|
|
43
|
+
const configSchema = {
|
|
44
|
+
pluginAlias: "SolarmanMonitor",
|
|
45
|
+
pluginType: "platform",
|
|
46
|
+
singular: true,
|
|
47
|
+
headerDisplay: "Homebridge plugin for SOLARMAN solar inverter monitoring.",
|
|
48
|
+
schema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
name: { title: "Name", type: "string", required: true, default: "Solarman" },
|
|
52
|
+
email: { title: "Email", type: "string", required: true, description: "Your SOLARMAN account email" },
|
|
53
|
+
password: { title: "Password", type: "string", required: true, description: "Your SOLARMAN account password" },
|
|
54
|
+
plantId: { title: "Plant ID", type: "integer", required: false, description: "Plant/Station ID (auto-detected if omitted)" },
|
|
55
|
+
pollingInterval: { title: "Polling Interval (seconds)", type: "integer", required: false, default: 60, minimum: 30, maximum: 600 }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
fs.writeFileSync('config.schema.json', JSON.stringify(configSchema, null, 2));
|
|
60
|
+
console.log('wrote config.schema.json');
|
|
61
|
+
|
|
62
|
+
// ============ src/settings.ts ============
|
|
63
|
+
fs.writeFileSync(path.join(srcDir, 'settings.ts'),
|
|
64
|
+
"export const PLATFORM_NAME = 'SolarmanMonitor';\nexport const PLUGIN_NAME = 'homebridge-solarman';\n"
|
|
65
|
+
);
|
|
66
|
+
console.log('wrote settings.ts');
|
|
67
|
+
|
|
68
|
+
// ============ src/index.ts ============
|
|
69
|
+
const indexContent = [
|
|
70
|
+
"imp" + "ort { API } from 'homebridge';",
|
|
71
|
+
"imp" + "ort { PLATFORM_NAME, PLUGIN_NAME } from './settings';",
|
|
72
|
+
"imp" + "ort { SolarmanPlatform } from './platform';",
|
|
73
|
+
"",
|
|
74
|
+
"export default (api: API) => {",
|
|
75
|
+
" api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, SolarmanPlatform);",
|
|
76
|
+
"};",
|
|
77
|
+
""
|
|
78
|
+
].join("\n");
|
|
79
|
+
fs.writeFileSync(path.join(srcDir, 'index.ts'), indexContent);
|
|
80
|
+
console.log('wrote index.ts');
|
|
81
|
+
|
|
82
|
+
// ============ src/solarmanApi.ts ============
|
|
83
|
+
const apiContent = [
|
|
84
|
+
"imp" + "ort axios, { AxiosInstance } from 'axios';",
|
|
85
|
+
"imp" + "ort { Logger } from 'homebridge';",
|
|
86
|
+
"imp" + "ort * as crypto from 'crypto';",
|
|
87
|
+
"",
|
|
88
|
+
"export interface SolarData {",
|
|
89
|
+
" generationPower: number; // W",
|
|
90
|
+
" usePower: number; // W",
|
|
91
|
+
" batterySoc: number; // %",
|
|
92
|
+
" buyPower: number; // W (negative = selling)",
|
|
93
|
+
" gridPower: number; // W",
|
|
94
|
+
"}",
|
|
95
|
+
"",
|
|
96
|
+
"export class SolarmanApi {",
|
|
97
|
+
" private client: AxiosInstance;",
|
|
98
|
+
" private token = '';",
|
|
99
|
+
" private tokenExpiry = 0;",
|
|
100
|
+
" private plantId: number | undefined;",
|
|
101
|
+
"",
|
|
102
|
+
" constructor(",
|
|
103
|
+
" private readonly email: string,",
|
|
104
|
+
" private readonly password: string,",
|
|
105
|
+
" plantId: number | undefined,",
|
|
106
|
+
" private readonly log: Logger,",
|
|
107
|
+
" ) {",
|
|
108
|
+
" this.plantId = plantId;",
|
|
109
|
+
" this.client = axios.create({",
|
|
110
|
+
" baseURL: 'https://home.solarman.cn',",
|
|
111
|
+
" timeout: 15000,",
|
|
112
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
113
|
+
" });",
|
|
114
|
+
" }",
|
|
115
|
+
"",
|
|
116
|
+
" private hashPassword(pwd: string): string {",
|
|
117
|
+
" return crypto.createHash('sha256').update(pwd).digest('hex');",
|
|
118
|
+
" }",
|
|
119
|
+
"",
|
|
120
|
+
" async login(): Promise<void> {",
|
|
121
|
+
" try {",
|
|
122
|
+
" const res = await this.client.post('/oauth-s/oauth/token', null, {",
|
|
123
|
+
" params: {",
|
|
124
|
+
" grant_type: 'mdc_password',",
|
|
125
|
+
" username: this.email,",
|
|
126
|
+
" clear_text_pwd: this.password,",
|
|
127
|
+
" password: this.hashPassword(this.password),",
|
|
128
|
+
" identity_type: 2,",
|
|
129
|
+
" client_id: 'test',",
|
|
130
|
+
" mdc: 'FOREIGN_1',",
|
|
131
|
+
" },",
|
|
132
|
+
" });",
|
|
133
|
+
" this.token = res.data.access_token;",
|
|
134
|
+
" this.tokenExpiry = Date.now() + (res.data.expires_in || 86400) * 1000;",
|
|
135
|
+
" this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.token;",
|
|
136
|
+
" this.log.info('SOLARMAN: authenticated successfully');",
|
|
137
|
+
" } catch (e) {",
|
|
138
|
+
" this.log.error('SOLARMAN: login failed', String(e));",
|
|
139
|
+
" throw e;",
|
|
140
|
+
" }",
|
|
141
|
+
" }",
|
|
142
|
+
"",
|
|
143
|
+
" private async ensureAuth(): Promise<void> {",
|
|
144
|
+
" if (!this.token || Date.now() > this.tokenExpiry - 60000) {",
|
|
145
|
+
" await this.login();",
|
|
146
|
+
" }",
|
|
147
|
+
" }",
|
|
148
|
+
"",
|
|
149
|
+
" async getPlantId(): Promise<number> {",
|
|
150
|
+
" if (this.plantId) return this.plantId;",
|
|
151
|
+
" await this.ensureAuth();",
|
|
152
|
+
" const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });",
|
|
153
|
+
" const plants = res.data?.data || [];",
|
|
154
|
+
" if (plants.length === 0) throw new Error('No plants found');",
|
|
155
|
+
" this.plantId = plants[0].id;",
|
|
156
|
+
" this.log.info('SOLARMAN: auto-detected plant ID:', this.plantId);",
|
|
157
|
+
" return this.plantId!;",
|
|
158
|
+
" }",
|
|
159
|
+
"",
|
|
160
|
+
" async getData(): Promise<SolarData> {",
|
|
161
|
+
" await this.ensureAuth();",
|
|
162
|
+
" const pid = await this.getPlantId();",
|
|
163
|
+
" const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });",
|
|
164
|
+
" const plants = res.data?.data || [];",
|
|
165
|
+
" const plant = plants.find((p: any) => p.id === pid) || plants[0];",
|
|
166
|
+
" if (!plant) throw new Error('Plant not found');",
|
|
167
|
+
" return {",
|
|
168
|
+
" generationPower: plant.generationPower || 0,",
|
|
169
|
+
" usePower: plant.usePower || 0,",
|
|
170
|
+
" batterySoc: plant.batterySoc || 0,",
|
|
171
|
+
" buyPower: plant.buyPower || 0,",
|
|
172
|
+
" gridPower: plant.gridPower || 0,",
|
|
173
|
+
" };",
|
|
174
|
+
" }",
|
|
175
|
+
"}",
|
|
176
|
+
""
|
|
177
|
+
].join("\n");
|
|
178
|
+
fs.writeFileSync(path.join(srcDir, 'solarmanApi.ts'), apiContent);
|
|
179
|
+
console.log('wrote solarmanApi.ts');
|
|
180
|
+
|
|
181
|
+
console.log('\nAll base files written!');
|
package/generate_main.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const srcDir = 'src';
|
|
5
|
+
|
|
6
|
+
// ============ src/platform.ts ============
|
|
7
|
+
const platformLines = [
|
|
8
|
+
"imp" + "ort {",
|
|
9
|
+
" API,",
|
|
10
|
+
" DynamicPlatformPlugin,",
|
|
11
|
+
" Logger,",
|
|
12
|
+
" PlatformAccessory,",
|
|
13
|
+
" PlatformConfig,",
|
|
14
|
+
" Service,",
|
|
15
|
+
" Characteristic,",
|
|
16
|
+
"} from 'homebridge';",
|
|
17
|
+
"imp" + "ort { PLATFORM_NAME, PLUGIN_NAME } from './settings';",
|
|
18
|
+
"imp" + "ort { SolarmanApi } from './solarmanApi';",
|
|
19
|
+
"imp" + "ort { SolarSensor, SensorType } from './solarSensor';",
|
|
20
|
+
"",
|
|
21
|
+
"const SENSOR_TYPES: SensorType[] = ['generation', 'consumption', 'battery', 'surplus'];",
|
|
22
|
+
"",
|
|
23
|
+
"export class SolarmanPlatform implements DynamicPlatformPlugin {",
|
|
24
|
+
" public readonly Service: typeof Service = this.api.hap.Service;",
|
|
25
|
+
" public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;",
|
|
26
|
+
" public solarApi!: SolarmanApi;",
|
|
27
|
+
" private pollingInterval: number;",
|
|
28
|
+
" private sensors: SolarSensor[] = [];",
|
|
29
|
+
"",
|
|
30
|
+
" constructor(",
|
|
31
|
+
" public readonly log: Logger,",
|
|
32
|
+
" public readonly config: PlatformConfig,",
|
|
33
|
+
" public readonly api: API,",
|
|
34
|
+
" ) {",
|
|
35
|
+
" this.pollingInterval = (config.pollingInterval || 60) * 1000;",
|
|
36
|
+
" this.api.on('didFinishLaunching', () => this.onReady());",
|
|
37
|
+
" }",
|
|
38
|
+
"",
|
|
39
|
+
" configureAccessory(accessory: PlatformAccessory): void {",
|
|
40
|
+
" // no-op for cached accessories",
|
|
41
|
+
" }",
|
|
42
|
+
"",
|
|
43
|
+
" private async onReady(): Promise<void> {",
|
|
44
|
+
" if (!this.config.email || !this.config.password) {",
|
|
45
|
+
" this.log.error('Missing email or password in config');",
|
|
46
|
+
" return;",
|
|
47
|
+
" }",
|
|
48
|
+
" this.solarApi = new SolarmanApi(",
|
|
49
|
+
" this.config.email,",
|
|
50
|
+
" this.config.password,",
|
|
51
|
+
" this.config.plantId,",
|
|
52
|
+
" this.log,",
|
|
53
|
+
" );",
|
|
54
|
+
"",
|
|
55
|
+
" try {",
|
|
56
|
+
" await this.solarApi.login();",
|
|
57
|
+
" } catch {",
|
|
58
|
+
" this.log.error('Failed to connect to SOLARMAN');",
|
|
59
|
+
" return;",
|
|
60
|
+
" }",
|
|
61
|
+
"",
|
|
62
|
+
" // Register 4 sensor accessories",
|
|
63
|
+
" const accessories: PlatformAccessory[] = [];",
|
|
64
|
+
" for (const sType of SENSOR_TYPES) {",
|
|
65
|
+
" const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + sType);",
|
|
66
|
+
" const accessory = new this.api.platformAccessory('Solar ' + sType, uuid);",
|
|
67
|
+
" const sensor = new SolarSensor(this, accessory, sType);",
|
|
68
|
+
" this.sensors.push(sensor);",
|
|
69
|
+
" accessories.push(accessory);",
|
|
70
|
+
" }",
|
|
71
|
+
" this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);",
|
|
72
|
+
" this.log.info('Registered ' + accessories.length + ' solar sensors');",
|
|
73
|
+
"",
|
|
74
|
+
" // Start polling",
|
|
75
|
+
" this.poll();",
|
|
76
|
+
" setInterval(() => this.poll(), this.pollingInterval);",
|
|
77
|
+
" }",
|
|
78
|
+
"",
|
|
79
|
+
" private async poll(): Promise<void> {",
|
|
80
|
+
" try {",
|
|
81
|
+
" const data = await this.solarApi.getData();",
|
|
82
|
+
" const genKW = data.generationPower / 1000;",
|
|
83
|
+
" const useKW = data.usePower / 1000;",
|
|
84
|
+
" const surplusKW = Math.max(0, genKW - useKW);",
|
|
85
|
+
" for (const s of this.sensors) {",
|
|
86
|
+
" switch (s.sensorType) {",
|
|
87
|
+
" case 'generation': s.updateValue(genKW); break;",
|
|
88
|
+
" case 'consumption': s.updateValue(useKW); break;",
|
|
89
|
+
" case 'battery': s.updateValue(data.batterySoc); break;",
|
|
90
|
+
" case 'surplus': s.updateValue(surplusKW); break;",
|
|
91
|
+
" }",
|
|
92
|
+
" }",
|
|
93
|
+
" this.log.debug('Solar: gen=' + genKW.toFixed(1) + 'kW use=' + useKW.toFixed(1) + 'kW bat=' + data.batterySoc + '% surplus=' + surplusKW.toFixed(1) + 'kW');",
|
|
94
|
+
" } catch (e) {",
|
|
95
|
+
" this.log.error('Polling failed:', String(e));",
|
|
96
|
+
" }",
|
|
97
|
+
" }",
|
|
98
|
+
"}",
|
|
99
|
+
""
|
|
100
|
+
];
|
|
101
|
+
fs.writeFileSync(path.join(srcDir, 'platform.ts'), platformLines.join("\n"));
|
|
102
|
+
console.log('wrote platform.ts');
|
|
103
|
+
|
|
104
|
+
// ============ src/solarSensor.ts ============
|
|
105
|
+
// Uses Thermostat service for visibility in Apple Home (same trick as ROTEX)
|
|
106
|
+
const sensorLines = [
|
|
107
|
+
"imp" + "ort {",
|
|
108
|
+
" Service,",
|
|
109
|
+
" PlatformAccessory,",
|
|
110
|
+
" CharacteristicValue,",
|
|
111
|
+
"} from 'homebridge';",
|
|
112
|
+
"imp" + "ort { SolarmanPlatform } from './platform';",
|
|
113
|
+
"",
|
|
114
|
+
"export type SensorType = 'generation' | 'consumption' | 'battery' | 'surplus';",
|
|
115
|
+
"",
|
|
116
|
+
"const SENSOR_CONFIG: Record<SensorType, { name: string; unit: string }> = {",
|
|
117
|
+
" generation: { name: 'Generacion Solar', unit: 'kW' },",
|
|
118
|
+
" consumption: { name: 'Consumo Casa', unit: 'kW' },",
|
|
119
|
+
" battery: { name: 'Bateria', unit: '%' },",
|
|
120
|
+
" surplus: { name: 'Excedente', unit: 'kW' },",
|
|
121
|
+
"};",
|
|
122
|
+
"",
|
|
123
|
+
"export class SolarSensor {",
|
|
124
|
+
" private service: Service;",
|
|
125
|
+
" private currentValue = 0;",
|
|
126
|
+
"",
|
|
127
|
+
" constructor(",
|
|
128
|
+
" private readonly platform: SolarmanPlatform,",
|
|
129
|
+
" private readonly accessory: PlatformAccessory,",
|
|
130
|
+
" public readonly sensorType: SensorType,",
|
|
131
|
+
" ) {",
|
|
132
|
+
" const config = SENSOR_CONFIG[sensorType];",
|
|
133
|
+
"",
|
|
134
|
+
" // Info service",
|
|
135
|
+
" this.accessory.getService(this.platform.Service.AccessoryInformation)!",
|
|
136
|
+
" .setCharacteristic(this.platform.Characteristic.Manufacturer, 'SOLARMAN')",
|
|
137
|
+
" .setCharacteristic(this.platform.Characteristic.Model, 'Solar Monitor')",
|
|
138
|
+
" .setCharacteristic(this.platform.Characteristic.SerialNumber, 'SM-' + sensorType);",
|
|
139
|
+
"",
|
|
140
|
+
" // Thermostat service (dummy - for visibility in Apple Home)",
|
|
141
|
+
" this.service = this.accessory.getService(this.platform.Service.Thermostat)",
|
|
142
|
+
" || this.accessory.addService(this.platform.Service.Thermostat);",
|
|
143
|
+
" this.service.setCharacteristic(this.platform.Characteristic.Name, config.name);",
|
|
144
|
+
"",
|
|
145
|
+
" // Lock to OFF mode (read-only thermostat)",
|
|
146
|
+
" this.service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState)",
|
|
147
|
+
" .onGet(() => 0);",
|
|
148
|
+
" this.service.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState)",
|
|
149
|
+
" .onGet(() => 0)",
|
|
150
|
+
" .onSet(() => {",
|
|
151
|
+
" this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, 0);",
|
|
152
|
+
" })",
|
|
153
|
+
" .setProps({ validValues: [0] });",
|
|
154
|
+
"",
|
|
155
|
+
" // Current temperature = our value",
|
|
156
|
+
" this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature)",
|
|
157
|
+
" .onGet(() => this.currentValue)",
|
|
158
|
+
" .setProps({ minValue: -100, maxValue: 200 });",
|
|
159
|
+
"",
|
|
160
|
+
" // Target temperature (locked)",
|
|
161
|
+
" this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)",
|
|
162
|
+
" .onGet(() => this.currentValue)",
|
|
163
|
+
" .onSet(() => {",
|
|
164
|
+
" this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.currentValue);",
|
|
165
|
+
" })",
|
|
166
|
+
" .setProps({ minValue: -100, maxValue: 200 });",
|
|
167
|
+
"",
|
|
168
|
+
" // Display in Celsius",
|
|
169
|
+
" this.service.getCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits)",
|
|
170
|
+
" .onGet(() => 0)",
|
|
171
|
+
" .onSet(() => {",
|
|
172
|
+
" this.service.updateCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits, 0);",
|
|
173
|
+
" });",
|
|
174
|
+
" }",
|
|
175
|
+
"",
|
|
176
|
+
" updateValue(value: number): void {",
|
|
177
|
+
" this.currentValue = Math.round(value * 10) / 10;",
|
|
178
|
+
" this.service.updateCharacteristic(",
|
|
179
|
+
" this.platform.Characteristic.CurrentTemperature,",
|
|
180
|
+
" this.currentValue,",
|
|
181
|
+
" );",
|
|
182
|
+
" this.service.updateCharacteristic(",
|
|
183
|
+
" this.platform.Characteristic.TargetTemperature,",
|
|
184
|
+
" this.currentValue,",
|
|
185
|
+
" );",
|
|
186
|
+
" }",
|
|
187
|
+
"}",
|
|
188
|
+
""
|
|
189
|
+
];
|
|
190
|
+
fs.writeFileSync(path.join(srcDir, 'solarSensor.ts'), sensorLines.join("\n"));
|
|
191
|
+
console.log('wrote solarSensor.ts');
|
|
192
|
+
|
|
193
|
+
console.log('\nAll source files complete!');
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-solarman",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge plugin for SOLARMAN solar inverter monitoring in Apple HomeKit",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "trama2000",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/trama2000/homebridge-solarman.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"homebridge-plugin",
|
|
18
|
+
"solarman",
|
|
19
|
+
"solar",
|
|
20
|
+
"inverter",
|
|
21
|
+
"battery",
|
|
22
|
+
"homekit"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"homebridge": ">=1.6.0",
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"axios": "^1.6.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"homebridge": "^1.8.0",
|
|
34
|
+
"typescript": "^5.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/plat_fix.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"content":"import {\n API,\n DynamicPlatformPlugin,\n Logger,\n PlatformAccessory,\n PlatformConfig,\n Service,\n Characteristic,\n} from 'homebridge';\nimport { PLATFORM_NAME, PLUGIN_NAME } from './settings';\nimport { SolarmanApi } from './solarmanApi';\nimport { SolarSensor, SensorType } from './solarSensor';\n\nconst SENSOR_TYPES: SensorType[] = ['generation', 'consumption', 'battery', 'surplus'];\n\nexport class SolarmanPlatform implements DynamicPlatformPlugin {\n public readonly Service: typeof Service = this.api.hap.Service;\n public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;\n public solarApi!: SolarmanApi;\n private pollingInterval: number;\n private sensors: SolarSensor[] = [];\n\n constructor(\n public readonly log: Logger,\n public readonly config: PlatformConfig,\n public readonly api: API,\n ) {\n this.pollingInterval = (config.pollingInterval || 60) * 1000;\n this.api.on('didFinishLaunching', () => this.onReady());\n }\n\n private readonly cachedAccessories: PlatformAccessory[] = [];\n\n configureAccessory(accessory: PlatformAccessory): void {\n this.log.info('Restoring cached accessory:', accessory.displayName);\n this.cachedAccessories.push(accessory);\n }\n\n private async onReady(): Promise<void> {\n if (!this.config.email || !this.config.password) {\n this.log.error('Missing email or password in config');\n return;\n }\n this.solarApi = new SolarmanApi(\n this.config.email,\n this.config.password,\n this.config.plantId,\n this.log,\n );\n\n try {\n await this.solarApi.login();\n } catch {\n this.log.error('Failed to connect to SOLARMAN');\n return;\n }\n\n // Register or restore 4 sensor accessories\n const newAccessories: PlatformAccessory[] = [];\n for (const sType of SENSOR_TYPES) {\n const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + sType);\n let accessory = this.cachedAccessories.find(a => a.UUID === uuid);\n if (!accessory) {\n accessory = new this.api.platformAccessory('Solar ' + sType, uuid);\n newAccessories.push(accessory);\n this.log.info('Creating new accessory:', sType);\n } else {\n this.log.info('Restoring cached accessory:', sType);\n }\n const sensor = new SolarSensor(this, accessory, sType);\n this.sensors.push(sensor);\n }\n if (newAccessories.length > 0) {\n this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);\n }\n this.log.info('Total solar sensors: ' + this.sensors.length);\n\n // Start polling\n this.poll();\n setInterval(() => this.poll(), this.pollingInterval);\n }\n\n private async poll(): Promise<void> {\n try {\n const data = await this.solarApi.getData();\n const genKW = data.generationPower / 1000;\n const useKW = data.usePower / 1000;\n const surplusKW = Math.max(0, genKW - useKW);\n for (const s of this.sensors) {\n switch (s.sensorType) {\n case 'generation': s.updateValue(genKW); break;\n case 'consumption': s.updateValue(useKW); break;\n case 'battery': s.updateValue(data.batterySoc); break;\n case 'surplus': s.updateValue(surplusKW); break;\n }\n }\n this.log.debug('Solar: gen=' + genKW.toFixed(1) + 'kW use=' + useKW.toFixed(1) + 'kW bat=' + data.batterySoc + '% surplus=' + surplusKW.toFixed(1) + 'kW');\n } catch (e) {\n this.log.error('Polling failed:', String(e));\n }\n }\n}"}
|
package/src/index.ts
ADDED
package/src/platform.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
API,
|
|
3
|
+
DynamicPlatformPlugin,
|
|
4
|
+
Logger,
|
|
5
|
+
PlatformAccessory,
|
|
6
|
+
PlatformConfig,
|
|
7
|
+
Service,
|
|
8
|
+
Characteristic,
|
|
9
|
+
} from 'homebridge';
|
|
10
|
+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings';
|
|
11
|
+
import { SolarmanApi } from './solarmanApi';
|
|
12
|
+
import { SolarSensor, SensorType } from './solarSensor';
|
|
13
|
+
|
|
14
|
+
const SENSOR_TYPES: SensorType[] = ['generation', 'consumption', 'battery', 'surplus'];
|
|
15
|
+
|
|
16
|
+
export class SolarmanPlatform implements DynamicPlatformPlugin {
|
|
17
|
+
public readonly Service: typeof Service = this.api.hap.Service;
|
|
18
|
+
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
|
|
19
|
+
public solarApi!: SolarmanApi;
|
|
20
|
+
private pollingInterval: number;
|
|
21
|
+
private sensors: SolarSensor[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
public readonly log: Logger,
|
|
25
|
+
public readonly config: PlatformConfig,
|
|
26
|
+
public readonly api: API,
|
|
27
|
+
) {
|
|
28
|
+
this.pollingInterval = (config.pollingInterval || 60) * 1000;
|
|
29
|
+
this.api.on('didFinishLaunching', () => this.onReady());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private readonly cachedAccessories: PlatformAccessory[] = [];
|
|
33
|
+
|
|
34
|
+
configureAccessory(accessory: PlatformAccessory): void {
|
|
35
|
+
this.log.info('Restoring cached accessory:', accessory.displayName);
|
|
36
|
+
this.cachedAccessories.push(accessory);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async onReady(): Promise<void> {
|
|
40
|
+
if (!this.config.email || !this.config.password) {
|
|
41
|
+
this.log.error('Missing email or password in config');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.solarApi = new SolarmanApi(
|
|
45
|
+
this.config.email,
|
|
46
|
+
this.config.password,
|
|
47
|
+
this.config.plantId,
|
|
48
|
+
this.log,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await this.solarApi.login();
|
|
53
|
+
} catch {
|
|
54
|
+
this.log.error('Failed to connect to SOLARMAN');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Register or restore 4 sensor accessories
|
|
59
|
+
const newAccessories: PlatformAccessory[] = [];
|
|
60
|
+
for (const sType of SENSOR_TYPES) {
|
|
61
|
+
const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + sType);
|
|
62
|
+
let accessory = this.cachedAccessories.find(a => a.UUID === uuid);
|
|
63
|
+
if (!accessory) {
|
|
64
|
+
accessory = new this.api.platformAccessory('Solar ' + sType, uuid);
|
|
65
|
+
newAccessories.push(accessory);
|
|
66
|
+
this.log.info('Creating new accessory:', sType);
|
|
67
|
+
} else {
|
|
68
|
+
this.log.info('Restoring cached accessory:', sType);
|
|
69
|
+
}
|
|
70
|
+
const sensor = new SolarSensor(this, accessory, sType);
|
|
71
|
+
this.sensors.push(sensor);
|
|
72
|
+
}
|
|
73
|
+
if (newAccessories.length > 0) {
|
|
74
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);
|
|
75
|
+
}
|
|
76
|
+
this.log.info('Total solar sensors: ' + this.sensors.length);
|
|
77
|
+
|
|
78
|
+
// Start polling
|
|
79
|
+
this.poll();
|
|
80
|
+
setInterval(() => this.poll(), this.pollingInterval);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async poll(): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const data = await this.solarApi.getData();
|
|
86
|
+
const genKW = data.generationPower / 1000;
|
|
87
|
+
const useKW = data.usePower / 1000;
|
|
88
|
+
const surplusKW = Math.max(0, genKW - useKW);
|
|
89
|
+
for (const s of this.sensors) {
|
|
90
|
+
switch (s.sensorType) {
|
|
91
|
+
case 'generation': s.updateValue(genKW); break;
|
|
92
|
+
case 'consumption': s.updateValue(useKW); break;
|
|
93
|
+
case 'battery': s.updateValue(data.batterySoc); break;
|
|
94
|
+
case 'surplus': s.updateValue(surplusKW); break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.log.debug('Solar: gen=' + genKW.toFixed(1) + 'kW use=' + useKW.toFixed(1) + 'kW bat=' + data.batterySoc + '% surplus=' + surplusKW.toFixed(1) + 'kW');
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.log.error('Polling failed:', String(e));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Service,
|
|
3
|
+
PlatformAccessory,
|
|
4
|
+
CharacteristicValue,
|
|
5
|
+
} from 'homebridge';
|
|
6
|
+
import { SolarmanPlatform } from './platform';
|
|
7
|
+
|
|
8
|
+
export type SensorType = 'generation' | 'consumption' | 'battery' | 'surplus';
|
|
9
|
+
|
|
10
|
+
const SENSOR_CONFIG: Record<SensorType, { name: string; unit: string }> = {
|
|
11
|
+
generation: { name: 'Generacion Solar', unit: 'kW' },
|
|
12
|
+
consumption: { name: 'Consumo Casa', unit: 'kW' },
|
|
13
|
+
battery: { name: 'Bateria', unit: '%' },
|
|
14
|
+
surplus: { name: 'Excedente', unit: 'kW' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class SolarSensor {
|
|
18
|
+
private service: Service;
|
|
19
|
+
private currentValue = 0;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly platform: SolarmanPlatform,
|
|
23
|
+
private readonly accessory: PlatformAccessory,
|
|
24
|
+
public readonly sensorType: SensorType,
|
|
25
|
+
) {
|
|
26
|
+
const config = SENSOR_CONFIG[sensorType];
|
|
27
|
+
|
|
28
|
+
// Info service
|
|
29
|
+
this.accessory.getService(this.platform.Service.AccessoryInformation)!
|
|
30
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'SOLARMAN')
|
|
31
|
+
.setCharacteristic(this.platform.Characteristic.Model, 'Solar Monitor')
|
|
32
|
+
.setCharacteristic(this.platform.Characteristic.SerialNumber, 'SM-' + sensorType);
|
|
33
|
+
|
|
34
|
+
// Thermostat service (dummy - for visibility in Apple Home)
|
|
35
|
+
this.service = this.accessory.getService(this.platform.Service.Thermostat)
|
|
36
|
+
|| this.accessory.addService(this.platform.Service.Thermostat);
|
|
37
|
+
this.service.setCharacteristic(this.platform.Characteristic.Name, config.name);
|
|
38
|
+
|
|
39
|
+
// Lock to OFF mode (read-only thermostat)
|
|
40
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState)
|
|
41
|
+
.onGet(() => 0);
|
|
42
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState)
|
|
43
|
+
.onGet(() => 0)
|
|
44
|
+
.onSet(() => {
|
|
45
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, 0);
|
|
46
|
+
})
|
|
47
|
+
.setProps({ validValues: [0] });
|
|
48
|
+
|
|
49
|
+
// Current temperature = our value
|
|
50
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature)
|
|
51
|
+
.onGet(() => this.currentValue)
|
|
52
|
+
.setProps({ minValue: -100, maxValue: 200 });
|
|
53
|
+
|
|
54
|
+
// Target temperature (locked)
|
|
55
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
|
|
56
|
+
.onGet(() => this.currentValue)
|
|
57
|
+
.onSet(() => {
|
|
58
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.currentValue);
|
|
59
|
+
})
|
|
60
|
+
.setProps({ minValue: -100, maxValue: 200 });
|
|
61
|
+
|
|
62
|
+
// Display in Celsius
|
|
63
|
+
this.service.getCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits)
|
|
64
|
+
.onGet(() => 0)
|
|
65
|
+
.onSet(() => {
|
|
66
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits, 0);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
updateValue(value: number): void {
|
|
71
|
+
this.currentValue = Math.round(value * 10) / 10;
|
|
72
|
+
this.service.updateCharacteristic(
|
|
73
|
+
this.platform.Characteristic.CurrentTemperature,
|
|
74
|
+
this.currentValue,
|
|
75
|
+
);
|
|
76
|
+
this.service.updateCharacteristic(
|
|
77
|
+
this.platform.Characteristic.TargetTemperature,
|
|
78
|
+
this.currentValue,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { Logger } from 'homebridge';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
export interface SolarData {
|
|
6
|
+
generationPower: number; // W
|
|
7
|
+
usePower: number; // W
|
|
8
|
+
batterySoc: number; // %
|
|
9
|
+
buyPower: number; // W (negative = selling)
|
|
10
|
+
gridPower: number; // W
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SolarmanApi {
|
|
14
|
+
private client: AxiosInstance;
|
|
15
|
+
private token = '';
|
|
16
|
+
private tokenExpiry = 0;
|
|
17
|
+
private plantId: number | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly email: string,
|
|
21
|
+
private readonly password: string,
|
|
22
|
+
plantId: number | undefined,
|
|
23
|
+
private readonly log: Logger,
|
|
24
|
+
) {
|
|
25
|
+
this.plantId = plantId;
|
|
26
|
+
this.client = axios.create({
|
|
27
|
+
baseURL: 'https://home.solarman.cn',
|
|
28
|
+
timeout: 15000,
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private hashPassword(pwd: string): string {
|
|
34
|
+
return crypto.createHash('sha256').update(pwd).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async login(): Promise<void> {
|
|
38
|
+
try {
|
|
39
|
+
const res = await this.client.post('/oauth-s/oauth/token', null, {
|
|
40
|
+
params: {
|
|
41
|
+
grant_type: 'mdc_password',
|
|
42
|
+
username: this.email,
|
|
43
|
+
clear_text_pwd: this.password,
|
|
44
|
+
password: this.hashPassword(this.password),
|
|
45
|
+
identity_type: 2,
|
|
46
|
+
client_id: 'test',
|
|
47
|
+
mdc: 'FOREIGN_1',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
this.token = res.data.access_token;
|
|
51
|
+
this.tokenExpiry = Date.now() + (res.data.expires_in || 86400) * 1000;
|
|
52
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.token;
|
|
53
|
+
this.log.info('SOLARMAN: authenticated successfully');
|
|
54
|
+
} catch (e) {
|
|
55
|
+
this.log.error('SOLARMAN: login failed', String(e));
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async ensureAuth(): Promise<void> {
|
|
61
|
+
if (!this.token || Date.now() > this.tokenExpiry - 60000) {
|
|
62
|
+
await this.login();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getPlantId(): Promise<number> {
|
|
67
|
+
if (this.plantId) return this.plantId;
|
|
68
|
+
await this.ensureAuth();
|
|
69
|
+
const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });
|
|
70
|
+
const plants = res.data?.data || [];
|
|
71
|
+
if (plants.length === 0) throw new Error('No plants found');
|
|
72
|
+
this.plantId = plants[0].id;
|
|
73
|
+
this.log.info('SOLARMAN: auto-detected plant ID:', this.plantId);
|
|
74
|
+
return this.plantId!;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getData(): Promise<SolarData> {
|
|
78
|
+
await this.ensureAuth();
|
|
79
|
+
const pid = await this.getPlantId();
|
|
80
|
+
const res = await this.client.post('/station-s/station/v2.0/list', { page: 1, size: 10 });
|
|
81
|
+
const plants = res.data?.data || [];
|
|
82
|
+
const plant = plants.find((p: any) => p.id === pid) || plants[0];
|
|
83
|
+
if (!plant) throw new Error('Plant not found');
|
|
84
|
+
return {
|
|
85
|
+
generationPower: plant.generationPower || 0,
|
|
86
|
+
usePower: plant.usePower || 0,
|
|
87
|
+
batterySoc: plant.batterySoc || 0,
|
|
88
|
+
buyPower: plant.buyPower || 0,
|
|
89
|
+
gridPower: plant.gridPower || 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": [
|
|
6
|
+
"ES2020"
|
|
7
|
+
],
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": false,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"resolveJsonModule": true
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/write_plat.js
ADDED