Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@
/plugins/WorldCup2026/ @TimWheeler-SQUP
/plugins/Zscaler/ @shawn149
/plugins/Healthchecks.io/ @adamkinniburgh
/plugins/MyEnergi/ @adamkinniburgh
13 changes: 13 additions & 0 deletions plugins/MyEnergi/v1/configValidation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"steps": [
{
"displayName": "Authenticate",
"dataStream": {
"name": "devices"
},
"required": true,
"error": "Could not connect. Check the hub serial number and API key match the gateway device in myenergi myaccount, and that the key has not been regenerated since.",
"success": "Connected successfully."
}
]
}
37 changes: 37 additions & 0 deletions plugins/MyEnergi/v1/custom_types.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[
{
"name": "Hub",
"sourceType": "Hub",
"icon": "wifi",
"singular": "Hub",
"plural": "Hubs"
},
{
"name": "Zappi",
"sourceType": "Zappi",
"icon": "car",
"singular": "Zappi",
"plural": "Zappis"
},
{
"name": "Eddi",
"sourceType": "Eddi",
"icon": "droplet",
"singular": "Eddi",
"plural": "Eddis"
},
{
"name": "Harvi",
"sourceType": "Harvi",
"icon": "gauge",
"singular": "Harvi",
"plural": "Harvis"
},
{
"name": "Libbi",
"sourceType": "Libbi",
"icon": "battery-full",
"singular": "Libbi",
"plural": "Libbis"
}
]
25 changes: 25 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/boostSchedules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "boostSchedules",
"displayName": "Boost Schedules",
"description": "Configured boost timer slots for each Zappi and Eddi",
"tags": ["Energy"],
"baseDataSourceName": "httpRequestScopedSingle",
"config": {
"httpMethod": "get",
"endpointPath": "cgi-boost-time-{{object.rawId}}",
"paging": { "mode": "none" },
"postRequestScript": "boostSchedules.js"
},
"matches": { "sourceType": { "type": "oneOf", "values": ["Zappi", "Eddi"] } },
"metadata": [
{ "name": "deviceName", "displayName": "Device", "shape": "string", "role": "label" },
{ "name": "deviceKey", "displayName": "Device key", "shape": "string", "role": "id", "visible": false },
{ "name": "deviceType", "displayName": "Type", "shape": "string" },
{ "name": "slot", "displayName": "Slot", "shape": "number" },
{ "name": "target", "displayName": "Target", "shape": "string" },
{ "name": "startTime", "displayName": "Start time", "shape": "string" },
{ "name": "duration", "displayName": "Duration", "shape": "minutes" },
{ "name": "days", "displayName": "Days", "shape": ["array", { "itemShape": "string" }] }
],
"timeframes": false
}
28 changes: 28 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/ctReadings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "ctReadings",
"displayName": "CT Readings",
"description": "Live power reading from each current transformer clamp on the selected devices",
"tags": ["Energy", "Devices"],
"baseDataSourceName": "httpRequestScoped",
"config": {
"httpMethod": "get",
"endpointPath": "cgi-jstatus-*",
"paging": { "mode": "none" },
"postRequestScript": "ctReadings.js"
},
"matches": {
"sourceType": { "type": "oneOf", "values": ["Zappi", "Eddi", "Harvi", "Libbi"] }
},
"metadata": [
{ "name": "ctLabel", "displayName": "CT", "shape": "string", "role": "label" },
{ "name": "deviceName", "displayName": "Device", "shape": "string" },
{ "name": "deviceKey", "displayName": "Device key", "shape": "string", "visible": false },
{ "name": "deviceType", "displayName": "Device type", "shape": "string" },
{ "name": "ct", "displayName": "CT number", "shape": "number" },
{ "name": "ctType", "displayName": "CT type", "shape": "string" },
{ "name": "power", "displayName": "Power (W)", "shape": ["number", { "decimalPlaces": 0 }], "role": "value" },
{ "name": "phase", "displayName": "Phase", "shape": "number" },
{ "name": "lastUpdated", "displayName": "Last updated", "shape": "date", "role": "timestamp" }
],
"timeframes": false
}
37 changes: 37 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/deviceStatus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "deviceStatus",
"displayName": "Device Status",
"description": "Live status, mode, power and temperatures for each Zappi, Eddi, Harvi and Libbi",
"tags": ["Energy"],
"baseDataSourceName": "httpRequestScoped",
"config": {
"httpMethod": "get",
"endpointPath": "cgi-jstatus-*",
"paging": { "mode": "none" },
"postRequestScript": "deviceStatus.js"
},
"matches": { "sourceType": { "type": "oneOf", "values": ["Zappi", "Eddi", "Harvi", "Libbi"] } },
"metadata": [
{ "name": "deviceName", "displayName": "Device", "shape": "string", "role": "label" },
{ "name": "deviceKey", "displayName": "Device key", "shape": "string", "role": "id", "visible": false },
{ "name": "deviceType", "displayName": "Type", "shape": "string" },
{ "name": "status", "displayName": "Status", "shape": "string" },
{ "name": "health", "displayName": "Health", "shape": "state" },
{ "name": "mode", "displayName": "Mode", "shape": "string" },
{ "name": "plugStatus", "displayName": "Plug status", "shape": "string" },
{ "name": "power", "displayName": "Power (W)", "shape": "number", "role": "value" },
{ "name": "energy", "displayName": "Energy (kWh)", "shape": ["number", { "decimalPlaces": 2 }] },
{ "name": "activeHeater", "displayName": "Active heater", "shape": "string" },
{ "name": "tank1Temperature", "displayName": "Tank 1 temperature (°C)", "shape": "number" },
{ "name": "tank2Temperature", "displayName": "Tank 2 temperature (°C)", "shape": "number" },
{ "name": "batteryLevel", "displayName": "Battery level", "shape": "percent" },
{ "name": "minGreenLevel", "displayName": "Minimum green level", "shape": "percent" },
{ "name": "boosting", "displayName": "Boosting", "shape": "boolean" },
{ "name": "voltage", "displayName": "Voltage (V)", "shape": ["number", { "decimalPlaces": 1 }] },
{ "name": "frequency", "displayName": "Frequency (Hz)", "shape": ["number", { "decimalPlaces": 2 }] },
{ "name": "gridPower", "displayName": "Grid power (W)", "shape": "number" },
{ "name": "lastUpdated", "displayName": "Last updated", "shape": "date", "role": "timestamp" },
{ "name": "firmware", "displayName": "Firmware", "shape": "string" }
],
"timeframes": false
}
30 changes: 30 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/devices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "devices",
"displayName": "Devices",
"description": "The hub and every Zappi, Eddi, Harvi and Libbi registered to it",
"tags": ["Devices", "Energy"],
"baseDataSourceName": "httpRequestUnscoped",
"visibility": { "type": "hidden" },
"config": {
"httpMethod": "get",
"endpointPath": "cgi-jstatus-*",
"paging": { "mode": "none" },
"postRequestScript": "devices.js"
},
"matches": "none",
"metadata": [
{ "name": "deviceKey", "displayName": "Device key", "shape": "string", "role": "id" },
{ "name": "name", "displayName": "Name", "shape": "string", "role": "label" },
{ "name": "sourceType", "displayName": "Type", "shape": "string" },
{ "name": "serial", "displayName": "Serial number", "shape": "string" },
{ "name": "firmware", "displayName": "Firmware", "shape": "string" },
{ "name": "phases", "displayName": "Phases", "shape": "number" },
{ "name": "ctTypes", "displayName": "CT types", "shape": ["array", { "itemShape": "string" }] },
{ "name": "heater1Name", "displayName": "Heater 1 name", "shape": "string" },
{ "name": "heater2Name", "displayName": "Heater 2 name", "shape": "string" },
{ "name": "batteryCapacity", "displayName": "Battery capacity (Wh)", "shape": "number" },
{ "name": "inverterSize", "displayName": "Inverter size (W)", "shape": "number" },
{ "name": "server", "displayName": "Server", "shape": "string" }
],
"timeframes": false
}
54 changes: 54 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/energyHistory.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "energyHistory",
"displayName": "Energy History",
"description": "Energy imported, exported, generated, diverted and boosted over time for the selected devices",
"tags": ["Energy"],
"baseDataSourceName": "httpRequestScopedSingle",
"config": {
"httpMethod": "get",
"endpointPath": "{{ (() => { const start = new Date(timeframe.start); const end = new Date(timeframe.end); const day = (d) => [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()].join('-'); if (end - start <= 86400000) { const from = new Date(Math.floor(start / 60000) * 60000); return 'cgi-jday-' + object.rawId + '-' + day(from) + '-' + from.getUTCHours() + '-' + from.getUTCMinutes() + '-' + Math.floor((end - from) / 60000); } const from = new Date(Math.floor(start / 3600000) * 3600000); return 'cgi-jdayhour-' + object.rawId + '-' + day(from) + '-' + from.getUTCHours() + '-' + Math.ceil((end - from) / 3600000); })() }}",
"paging": {"mode": "none"},
"postRequestScript": "energyHistory.js"
},
"matches": {
"sourceType": {
"type": "oneOf",
"values": ["Zappi", "Eddi", "Libbi"]
}
},
"ui": [
{
"type": "choiceChips",
"name": "measure",
"label": "Measure",
"defaultValue": "energy",
"options": [
{"value": "energy", "label": "Energy (kWh)"},
{"value": "power", "label": "Average power (kW)"}
]
}
],
"metadata": [
{"name": "timestamp", "displayName": "Time", "shape": "date", "role": "timestamp"},
{"name": "deviceName", "displayName": "Device", "shape": "string", "role": "label"},
{"name": "deviceKey", "displayName": "Device key", "shape": "string", "visible": false},
{"name": "deviceType", "displayName": "Device type", "shape": "string"},
{"name": "import", "displayName": "Grid import", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "export", "displayName": "Grid export", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "generation", "displayName": "Generation", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "diverted", "displayName": "Diverted", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "boosted", "displayName": "Boosted", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "batteryCharge", "displayName": "Battery charge", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "batteryDischarge", "displayName": "Battery discharge", "shape": ["number", {"decimalPlaces": 3}]},
{"name": "unit", "displayName": "Unit", "shape": "string", "role": "unitLabel"}
],
"timeframes": [
"last1hour",
"last12hours",
"last24hours",
"last7days",
"last30days",
"thisMonth",
"lastMonth"
]
}
27 changes: 27 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/powerFlow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "powerFlow",
"displayName": "Power Flow",
"description": "Live site power flow including grid, generation, EV charging, hot water and battery",
"tags": ["Energy"],
"baseDataSourceName": "httpRequestUnscoped",
"config": {
"httpMethod": "get",
"endpointPath": "cgi-jstatus-*",
"paging": { "mode": "none" },
"postRequestScript": "powerFlow.js"
},
"matches": "none",
"metadata": [
{ "name": "gridPower", "displayName": "Grid (W)", "shape": "number" },
{ "name": "importPower", "displayName": "Import (W)", "shape": "number" },
{ "name": "exportPower", "displayName": "Export (W)", "shape": "number" },
{ "name": "generationPower", "displayName": "Generation (W)", "shape": "number" },
{ "name": "consumptionPower", "displayName": "Consumption (W)", "shape": "number", "role": "value" },
{ "name": "houseLoadPower", "displayName": "House load (W)", "shape": "number" },
{ "name": "evChargePower", "displayName": "EV charging (W)", "shape": "number" },
{ "name": "hotWaterPower", "displayName": "Hot water (W)", "shape": "number" },
{ "name": "batteryPower", "displayName": "Battery (W)", "shape": "number" },
{ "name": "lastUpdated", "displayName": "Last updated", "shape": "date", "role": "timestamp" }
],
"timeframes": false
}
40 changes: 40 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/scripts/boostSchedules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// cgi-boost-time-{key} returns every slot the device has, configured or not, as
// { boost_times: [{ slt, bsh, bsm, bdh, bdm, bdd }] }. An unset slot has an all-zero
// day mask, so a slot only counts as a schedule when at least one day is set.
// bdd is eight characters: a constant leading 0, then Monday to Sunday.
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const pad = (n) => String(n ?? 0).padStart(2, "0");

const unwrap = (v) => (Array.isArray(v) ? v[0] : v);
const object = context.objects[0];
const deviceKey = unwrap(object.rawId);
const deviceName = unwrap(object.name);
const deviceType = deviceKey.startsWith("Z") ? "Zappi" : "Eddi";

// The slot numbering for an Eddi is not documented (see the slot tens digit: 1, 2, 5, 6
// all appear, with zero durations), so an Eddi slot gets a neutral output label
// derived from the tens digit rather than a guessed heater or relay name.
const targetFor = (slot) =>
deviceType === "Zappi" ? "EV" : `Output ${Math.floor(slot / 10)}`;

const slots = Array.isArray(data?.boost_times) ? data.boost_times : [];

result = slots
.map((s) => {
const mask = String(s.bdd ?? "").padStart(8, "0");
const days = dayNames.filter((_, i) => mask[i + 1] === "1");
// A duration of 0 is reported for Eddi slots and means "no fixed length",
// not zero minutes, so it is returned as blank.
const minutes = (s.bdh ?? 0) * 60 + (s.bdm ?? 0);
return {
deviceName,
deviceKey,
deviceType,
slot: s.slt,
target: targetFor(s.slt),
startTime: `${pad(s.bsh)}:${pad(s.bsm)}`,
duration: minutes > 0 ? minutes : null,
days,
};
})
.filter((row) => row.days.length > 0);
61 changes: 61 additions & 0 deletions plugins/MyEnergi/v1/dataStreams/scripts/ctReadings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// cgi-jstatus-* returns every device on the hub in one response, so this scoped stream
// makes a single request and keeps only the devices that were selected.
const families = {
zappi: { type: "Zappi", prefix: "Z" },
eddi: { type: "Eddi", prefix: "E" },
harvi: { type: "Harvi", prefix: "H" },
libbi: { type: "Libbi", prefix: "L" },
};

const selected = new Map(
(context.objects || []).map((o) => [o.rawId, o.name]),
);

// dat is DD-MM-YYYY and tim HH:MM:SS (UTC); Libbi pads tim with spaces ("14: 53: 03").
const toIso = ({ dat, tim }) => {
const date = /^(\d{2})-(\d{2})-(\d{4})$/.exec(String(dat || "").trim());
const time = /^(\d{1,2}):(\d{2}):(\d{2})$/.exec(String(tim || "").replace(/\s/g, ""));
if (!date || !time) {
return null;
}
return `${date[3]}-${date[2]}-${date[1]}T${time[1].padStart(2, "0")}:${time[2]}:${time[3]}Z`;
};

const rows = [];

for (const element of Array.isArray(data) ? data : []) {
for (const [key, value] of Object.entries(element || {})) {
const family = families[key];
if (!family || !Array.isArray(value)) {
continue;
}
for (const device of value) {
const deviceKey = `${family.prefix}${device?.sno}`;
if (device?.sno === undefined || !selected.has(deviceKey)) {
continue;
}
const deviceName = selected.get(deviceKey) || `${family.type} ${device.sno}`;
const lastUpdated = toIso(device);
for (const n of [1, 2, 3, 4, 5, 6]) {
const ctType = device[`ectt${n}`];
// Unused clamps are reported as "None"; a used clamp reading 0 W omits ectpN.
if (!ctType || ctType === "None") {
continue;
}
rows.push({
ctLabel: `${deviceName} · CT${n} · ${ctType}`,
deviceName,
deviceKey,
deviceType: family.type,
ct: n,
ctType,
power: Math.round(device[`ectp${n}`] || 0),
phase: device[`ect${n}p`] ?? null,
lastUpdated,
});
}
}
}
}

result = rows;
Loading
Loading