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
5 changes: 5 additions & 0 deletions .changeset/node-red-energy-catalog-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@teslemetry/node-red-contrib-teslemetry": minor
---

The Energy Command node lists every Teslemetry API energy site command, with a field for each argument, and runs any of them from `msg.command` by its route name with its arguments in `msg.payload`. This adds backup events, gateway config and diagnostics. Commands that need a key the gateway trusts are offered for a site paired with Teslemetry or with a Local gateway, and backup reserve, operation mode, grid export, dispatch and island mode go to the Local gateway first. Nodes saved with an earlier command name keep working unchanged.
7 changes: 5 additions & 2 deletions packages/node-red-contrib-teslemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ Send commands to a Tesla Energy Site or retrieve site status.
**Configuration:**
- **Site ID**: Select a site or leave empty to use `msg.siteId`.
- **Local gateway**: Optional. A Teslemetry Local Powerwall config node. Backup reserve, operation mode, grid export, dispatch, resume, go off-grid and reconnect to grid then go to the gateway over your local network first, and through the cloud only if the gateway does not answer. The node status shows `local` or `cloud`.
- **Command**: Select a command, or choose **From msg.command** to take it from each message.
- **Command**: Select a command, or choose **From msg.command** to take it from each message. The list holds every energy site command in the Teslemetry API, grouped as the API groups them. Commands that need a key the gateway trusts (dispatch, backup events, island mode, gateway config and diagnostics) are greyed out for a site that is not paired with Teslemetry, except dispatch, resume and island mode when this node has a Local gateway for the site, which signs those itself. A node saved with an earlier command name keeps it, under **Earlier names (still supported)**.
- **Fields**: Each argument of the selected command, as a fixed value or read from `msg`, `flow`, `global`, an environment variable or JSONata. By default each reads `msg.payload.<field>`.

A site or command selected in the node always wins; `msg.siteId` and `msg.command` are only read when the matching dropdown is set to its **From msg…** option. Command arguments are read from the top level of the message, not from `msg.payload`.
A site or command selected in the node always wins; `msg.siteId` and `msg.command` are only read when the matching dropdown is set to its **From msg…** option. Earlier command names (`setBackupReserve`, ...) read their arguments from the top level of the message, not from `msg.payload`.

`msg.command` also takes any energy site command from the [Teslemetry API](https://teslemetry.com/docs) by its route name, such as `backup`, `storm_mode` or `schedule_backup_event`. These read their arguments from `msg.payload` using the API's field names (a field set in the node wins over `msg.payload`), for example `msg.payload = { "backup_reserve_percent": 40 }`. With a Local gateway, `backup`, `operation`, `grid_import_export`, `dispatch_battery_power`, `resume_battery_operation` and `set_island_mode` go to the gateway first, as the earlier names do.

**Inputs:**
- `msg.siteId` (number): Energy Site ID (when Site ID is **From msg.siteId**).
Expand Down
29 changes: 29 additions & 0 deletions packages/node-red-contrib-teslemetry/src/localCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { SiteCommands } from "./localPowerwall";

type Body = Record<string, any>;

/** The gateway's positional arguments for an API command body, by API route name. */
const LOCAL_ARGS: { [M in keyof SiteCommands]: (body: Body) => Parameters<SiteCommands[M]> } = {
backup: (b) => [b.backup_reserve_percent],
operation: (b) => [b.default_real_mode],
grid_import_export: (b) => [
b.disallow_charge_from_grid_with_solar_installed ?? null,
b.customer_preferred_export_rule,
],
dispatch_battery_power: (b) => [b.power_watts, b.duration_seconds],
resume_battery_operation: () => [],
set_island_mode: (b) => [b.mode, b.force ?? null],
};

/** The gateway method and arguments for an API command, or undefined when the gateway does not take it. */
export function localCommand(
name: string,
body: Body,
): { method: keyof SiteCommands; args: unknown[] } | undefined {
if (!Object.prototype.hasOwnProperty.call(LOCAL_ARGS, name)) return undefined;
const method = name as keyof SiteCommands;
return { method, args: LOCAL_ARGS[method](body) };
}

/** The API route names a gateway takes. */
export const LOCAL_COMMANDS = new Set(Object.keys(LOCAL_ARGS));
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Node, NodeAPI, NodeDef } from "node-red";
import {
Teslemetry,
TeslemetryStreamOptions,
ENERGY_COMMANDS,
useTeslaModel,
VEHICLE_COMMANDS,
} from "@teslemetry/api";
import { createLogger, instances, getErrorMessage, Instance } from "../shared";
import { LOCAL_COMMANDS } from "../localCommands";
import pkg from "../../package.json" with { type: "json" };

const CLIENT = `${pkg.name}/${pkg.version}`;
Expand Down Expand Up @@ -214,7 +216,17 @@ export default function (RED: NodeAPI) {
});

// The vehicle model comes from the VIN, so a VIN filters the list without a request.
// Energy commands are filtered in the editor, which knows the node's Local gateway.
RED.httpAdmin.get("/teslemetry/commands", canRead, (req: any, res: any) => {
if (req.query.product === "energy") {
res.json(
Object.values(ENERGY_COMMANDS).map((spec) => ({
...spec,
local: LOCAL_COMMANDS.has(spec.name),
})),
);
return;
}
const vin = typeof req.query.vin === "string" ? req.query.vin : "";
const model = vin ? useTeslaModel(vin) : undefined;
res.json(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<script
type="text/javascript"
src="resources/@teslemetry/node-red-contrib-teslemetry/command-editor.js"
></script>
<script type="text/javascript">
RED.nodes.registerType("teslemetry-energy-command", {
category: "Teslemetry Energy",
Expand All @@ -16,7 +20,8 @@
type: "teslemetry-local-powerwall",
required: false,
},
command: { value: "getLiveStatus" },
command: { value: "live_status" },
args: { value: {} },
},
inputs: 1,
outputs: 1,
Expand All @@ -28,31 +33,42 @@
const node = this;
const siteSelect = $("#node-input-siteId");
const configInput = $("#node-input-teslemetryConfig");
const pairedOnly = $(
"#node-input-command option.teslemetry-paired-only",
);
const paired = {};
let catalog = {};

const gatewayInput = $("#node-input-localGateway");

// Dispatch, resume, off-grid and reconnect need a key the gateway trusts: Node-RED's
// own through a local gateway for the site, or Teslemetry's
// (the site is paired). They are greyed out for a site with
// neither. With "From msg.siteId" the site is unknown here; the
// Commands that need `paired` need a key the gateway trusts:
// Teslemetry's (the site is paired), or Node-RED's own through a
// local gateway for the site, which only covers the commands the
// gateway takes. They are greyed out otherwise. With "From msg.siteId" the site is unknown here; the
// node refuses at runtime instead. The selected option is never
// disabled: jQuery's val() returns null for a disabled one,
// which would clear the saved command on Done.
function updateCommands() {
const siteId = siteSelect.val();
const gateway = RED.nodes.node(gatewayInput.val());
const local = !!gateway && gateway.siteId === siteId;
const unpaired =
!!siteId && paired[siteId] !== true && !local;
pairedOnly.each(function () {
this.disabled = unpaired && !this.selected;
const unpaired = !!siteId && paired[siteId] !== true;
$("#node-input-command option").each(function () {
const spec = catalog[this.value];
// The earlier paired-only names all have a local form.
const legacy = $(this).hasClass("teslemetry-paired-only");
const needsKey = legacy || (spec && spec.paired);
const viaGateway = local && (legacy || (spec && spec.local));
this.disabled =
unpaired && needsKey && !viaGateway && !this.selected;
});
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
}

const loadCommands = TeslemetryCommandEditor.setup(
node,
function (loaded) {
catalog = loaded;
updateCommands();
},
);

function updateSites() {
const configId = configInput.val();
if (configId && configId !== "_ADD_") {
Expand Down Expand Up @@ -100,6 +116,10 @@
if (configInput.val()) {
updateSites();
}
loadCommands({ product: "energy" });
},
oneditsave: function () {
TeslemetryCommandEditor.save(this);
},
});
</script>
Expand Down Expand Up @@ -135,9 +155,10 @@
</label>
<select id="node-input-command">
<option value="">From msg.command</option>
<option value="getTariff">Get Tariff (TOU rates)</option>
<optgroup id="node-input-command-earlier" label="Earlier names (still supported)">
<option value="getLiveStatus">Get Live Status</option>
<option value="getSiteInfo">Get Site Info</option>
<option value="getTariff">Get Tariff (TOU rates)</option>
<option value="setOperationModeSelfConsumption">
Mode: Self Consumption
</option>
Expand All @@ -155,35 +176,32 @@
Set Time-of-Use Tariff (msg.tariffContentV2) - REPLACES
entire schedule
</option>
<optgroup label="Grid Control">
<option value="gridExportEverything">Export Everything</option>
<option value="gridExportSolar">Export Solar</option>
<option value="gridExportNothing">Export Nothing</option>
</optgroup>
<optgroup label="Battery Dispatch (local gateway or paired site)">
<option
class="teslemetry-paired-only"
value="dispatchBatteryPower"
>
Dispatch Battery Power (msg.powerWatts, msg.durationSeconds)
</option>
<option
class="teslemetry-paired-only"
value="resumeBatteryOperation"
>
Resume Battery Operation
</option>
</optgroup>
<optgroup label="Grid Connection (local gateway or paired site)">
<option class="teslemetry-paired-only" value="goOffGrid">
Go Off-Grid (runs the home from the battery)
</option>
<option class="teslemetry-paired-only" value="reconnectGrid">
Reconnect to Grid
</option>
<option value="gridExportEverything">Export Everything</option>
<option value="gridExportSolar">Export Solar</option>
<option value="gridExportNothing">Export Nothing</option>
<option
class="teslemetry-paired-only"
value="dispatchBatteryPower"
>
Dispatch Battery Power (msg.powerWatts, msg.durationSeconds)
</option>
<option
class="teslemetry-paired-only"
value="resumeBatteryOperation"
>
Resume Battery Operation
</option>
<option class="teslemetry-paired-only" value="goOffGrid">
Go Off-Grid (runs the home from the battery)
</option>
<option class="teslemetry-paired-only" value="reconnectGrid">
Reconnect to Grid
</option>
</optgroup>
</select>
<div id="node-input-command-badge"></div>
</div>
<div id="node-input-args-container"></div>
</script>

<script type="text/html" data-help-name="teslemetry-energy-command">
Expand All @@ -202,7 +220,17 @@ <h3>Inputs</h3>
<dd>
Command to execute, e.g. <code>setStormModeOn</code>. Only used
when Command is set to <b>From msg.command</b>; a command selected
in the node takes precedence.
in the node takes precedence. Also takes any Teslemetry API energy
site command by its route name, e.g. <code>backup</code> or
<code>storm_mode</code>.
</dd>
<dt class="optional">
payload <span class="property-type">object</span>
</dt>
<dd>
Arguments for an API route name command, using the API's field
names, e.g. <code>{ "backup_reserve_percent": 40 }</code> for
<code>backup</code>.
</dd>
<dt class="optional">
percentage / percent <span class="property-type">number</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Node, NodeAPI, NodeDef } from "node-red";
import { Teslemetry } from "@teslemetry/api";
import { ENERGY_COMMANDS, Teslemetry } from "@teslemetry/api";
import { failInput, getInstance } from "../shared";
import { catalogBody, CommandArg, resolveArgs, sendCatalogCommand } from "../catalog";
import { validateParameters, ValidationRules } from "../validation";
import { Msg } from "../types";
import type { SiteCommands } from "../localPowerwall";
import { localCommand } from "../localCommands";
import type { TeslemetryLocalPowerwallNode } from "./teslemetry-local-powerwall";

/**
Expand Down Expand Up @@ -84,12 +86,14 @@ export interface TeslemetryEnergyCommandNodeDef extends NodeDef {
command: string;
/** A Teslemetry Local Powerwall config node. Absent on nodes saved before the field existed. */
localGateway?: string;
args?: Record<string, CommandArg>;
}

export interface TeslemetryEnergyCommandNode extends Node {
teslemetry?: Teslemetry;
siteId: string;
command: string;
args?: Record<string, CommandArg>;
}

export default function (RED: NodeAPI) {
Expand All @@ -106,6 +110,7 @@ export default function (RED: NodeAPI) {
node.teslemetry = instance.teslemetry;
node.siteId = config.siteId;
node.command = config.command;
node.args = config.args;
node.status({});

const gateway = config.localGateway
Expand Down Expand Up @@ -144,20 +149,19 @@ export default function (RED: NodeAPI) {

const cloud = cloudCommands(site);
let path: "local" | "cloud" = "cloud";
/** Local first when this node has a gateway for the site, else the cloud. */
const run = async <M extends keyof SiteCommands>(
method: M,
...args: Parameters<SiteCommands[M]>
/** Through the gateway, and through `fallback` only when the gateway gives no answer. */
const sendLocal = async (
gateway: NonNullable<typeof local>,
method: keyof SiteCommands,
args: unknown[],
fallback: SiteCommands | null,
): Promise<{ response?: any }> => {
if (!local) {
return (cloud[method] as (...a: unknown[]) => Promise<any>)(...args);
}
const { sendLocalFirst } = await import("../localPowerwall");
const sent = await sendLocalFirst(
await local(),
cloudCanRun ? cloud : null,
await gateway(),
fallback,
method,
args,
args as Parameters<SiteCommands[typeof method]>,
);
path = sent.path;
if (sent.failover) {
Expand All @@ -167,6 +171,16 @@ export default function (RED: NodeAPI) {
}
return sent.result;
};
/** Local first when this node has a gateway for the site, else the cloud. */
const run = async <M extends keyof SiteCommands>(
method: M,
...args: Parameters<SiteCommands[M]>
): Promise<{ response?: any }> => {
if (!local) {
return (cloud[method] as (...a: unknown[]) => Promise<any>)(...args);
}
return sendLocal(local, method, args, cloudCanRun ? cloud : null);
};

let result: { response?: any };

Expand Down Expand Up @@ -266,8 +280,34 @@ export default function (RED: NodeAPI) {
case "reconnectGrid":
result = await run("set_island_mode", 1, null);
break;
default:
throw new Error(`Unknown command: ${command}`);
default: {
// Catalog commands take their arguments from msg.payload and the node's fields.
const spec = Object.prototype.hasOwnProperty.call(ENERGY_COMMANDS, command)
? ENERGY_COMMANDS[command]
: undefined;
if (!spec) throw new Error(`Unknown command: ${command}`);
const body = catalogBody(
spec,
await resolveArgs(RED, node, msg, node.command ? node.args : undefined),
);
const toLocal = local ? localCommand(spec.name, body) : undefined;
if (spec.paired) {
const { energySites } = await instance.products;
cloudCanRun = energySites[siteId]?.metadata.paired === true;
if (!cloudCanRun && !toLocal) throw new Error(NOT_PAIRED_MESSAGE);
}
const sendCloud = () =>
sendCatalogCommand(node.teslemetry!, spec, { id: Number(siteId) }, body);
result = toLocal
? await sendLocal(
local!,
toLocal.method,
toLocal.args,
// The router calls the fallback by the gateway's method name.
cloudCanRun ? ({ [toLocal.method]: sendCloud } as unknown as SiteCommands) : null,
)
: await sendCloud();
}
}

msg.payload = result.response;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,12 @@ test("the commands list leaves out commands for another vehicle model", () => {
assert.ok(!list("5YJYGDEE0LF000000").includes("set_outlets"));
assert.ok(list("7G2CEHED0RA000000").includes("set_outlets"));
assert.ok(list().includes("set_outlets"));

let energy: { name: string }[] = [];
gets.get("/teslemetry/commands")!({ query: { product: "energy" } }, { json: (b: any) => (energy = b) });
assert.ok(energy.some((spec) => spec.name === "dispatch_battery_power"));
assert.ok(!energy.some((spec) => spec.name === "set_charge_limit"));
const local = (name: string) => (energy.find((spec) => spec.name === name) as any).local;
assert.equal(local("backup"), true);
assert.equal(local("storm_mode"), false);
});
Loading
Loading