From f57ba061dec4895e83120a51be15e4b5fee41031 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 22 Oct 2026 08:39:58 +1000 Subject: [PATCH 1/4] feat(n8n): call the Teslemetry API through n8n's HTTP helper The Vehicle and Energy nodes no longer bundle @teslemetry/api. A node-local teslemetryRequest() sends each request through this.helpers.httpRequestWithAuthentication with the package's credential and maps failures to NodeApiError with the API's text and status. Sources move to n8n's standard nodes// and credentials/ layout, which n8n's lint and scanner read. Operations are grouped by resource, labels follow n8n's lint rules, and every output item carries pairedItem. The SSE Trigger is removed here and rebuilt as a polling trigger in the next commit. --- .github/workflows/reusable-ci.yml | 2 +- .../credentials/TeslemetryApi.credentials.ts | 2 + .../credentials/teslemetry.svg | 3 + .../TeslemetryEnergy.node.ts | 187 +++-- .../TeslemetryEnergy/teslemetryEnergy.svg | 3 + .../TeslemetryVehicle.node.ts | 695 +++++++++--------- .../TeslemetryVehicle}/teslemetryVehicle.svg | 0 packages/n8n-nodes-teslemetry/nodes/shared.ts | 143 ++++ packages/n8n-nodes-teslemetry/package.json | 9 +- .../src/nodes/TeslemetryTrigger.node.ts | 329 --------- packages/n8n-nodes-teslemetry/src/shared.ts | 152 ---- .../test/credential.test.ts | 2 +- .../test/energyExecute.test.ts | 2 +- .../test/loadOptions.test.ts | 32 +- .../n8n-nodes-teslemetry/test/testHelpers.ts | 61 +- .../test/triggerFilters.test.ts | 192 ----- .../test/vehicleExecute.test.ts | 2 +- packages/n8n-nodes-teslemetry/tsconfig.json | 7 +- .../n8n-nodes-teslemetry/tsdown.config.ts | 4 +- pnpm-lock.yaml | 3 - 20 files changed, 663 insertions(+), 1167 deletions(-) rename packages/n8n-nodes-teslemetry/{src => }/credentials/TeslemetryApi.credentials.ts (95%) create mode 100644 packages/n8n-nodes-teslemetry/credentials/teslemetry.svg rename packages/n8n-nodes-teslemetry/{src/nodes => nodes/TeslemetryEnergy}/TeslemetryEnergy.node.ts (51%) create mode 100644 packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/teslemetryEnergy.svg rename packages/n8n-nodes-teslemetry/{src/nodes => nodes/TeslemetryVehicle}/TeslemetryVehicle.node.ts (61%) rename packages/n8n-nodes-teslemetry/{src/nodes => nodes/TeslemetryVehicle}/teslemetryVehicle.svg (100%) create mode 100644 packages/n8n-nodes-teslemetry/nodes/shared.ts delete mode 100644 packages/n8n-nodes-teslemetry/src/nodes/TeslemetryTrigger.node.ts delete mode 100644 packages/n8n-nodes-teslemetry/src/shared.ts delete mode 100644 packages/n8n-nodes-teslemetry/test/triggerFilters.test.ts diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 7d7f185..0226331 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -39,7 +39,7 @@ jobs: run: pnpm test - name: Verify the n8n package loads from its packed tarball - # The tests import the node classes from src/. n8n instead reads only + # The tests import the node classes from nodes/. n8n instead reads only # the files the manifest lists and takes each class name from its file # name, so this packs the tarball and loads it with n8n's own # PackageDirectoryLoader, failing unless 3 nodes and 1 credential load. diff --git a/packages/n8n-nodes-teslemetry/src/credentials/TeslemetryApi.credentials.ts b/packages/n8n-nodes-teslemetry/credentials/TeslemetryApi.credentials.ts similarity index 95% rename from packages/n8n-nodes-teslemetry/src/credentials/TeslemetryApi.credentials.ts rename to packages/n8n-nodes-teslemetry/credentials/TeslemetryApi.credentials.ts index 602a63c..00e6489 100644 --- a/packages/n8n-nodes-teslemetry/src/credentials/TeslemetryApi.credentials.ts +++ b/packages/n8n-nodes-teslemetry/credentials/TeslemetryApi.credentials.ts @@ -2,6 +2,7 @@ import { IAuthenticateGeneric, ICredentialTestRequest, ICredentialType, + Icon, INodeProperties, } from 'n8n-workflow'; @@ -9,6 +10,7 @@ export class TeslemetryApi implements ICredentialType { name = 'teslemetryApi'; displayName = 'Teslemetry API'; documentationUrl = 'https://teslemetry.com/docs'; + icon: Icon = 'file:teslemetry.svg'; properties: INodeProperties[] = [ { displayName: 'Access Token', diff --git a/packages/n8n-nodes-teslemetry/credentials/teslemetry.svg b/packages/n8n-nodes-teslemetry/credentials/teslemetry.svg new file mode 100644 index 0000000..f89764c --- /dev/null +++ b/packages/n8n-nodes-teslemetry/credentials/teslemetry.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryEnergy.node.ts b/packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/TeslemetryEnergy.node.ts similarity index 51% rename from packages/n8n-nodes-teslemetry/src/nodes/TeslemetryEnergy.node.ts rename to packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/TeslemetryEnergy.node.ts index 3778b9b..26822f7 100644 --- a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryEnergy.node.ts +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/TeslemetryEnergy.node.ts @@ -1,27 +1,67 @@ import { + IDataObject, IExecuteFunctions, + IHttpRequestMethods, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; -import { createTeslemetry, errorText, loadWithTeslemetry, toNodeError } from '../shared'; +import { errorText, loadOptions, siteOptions, teslemetryRequest, toNodeError } from '../shared'; + +type SiteInfo = { response?: { tariff_id?: unknown; tariff_content?: unknown; tariff_content_v2?: unknown } }; + +// Each operation's request, relative to /api/1/energy_sites/{id}/. +const OPERATIONS: Record< + string, + (param: (name: string) => IDataObject[string]) => { method: IHttpRequestMethods; path: string; body?: IDataObject } +> = { + getLiveStatus: () => ({ method: 'GET', path: 'live_status' }), + getSiteInfo: () => ({ method: 'GET', path: 'site_info' }), + setBackupReserve: (param) => ({ + method: 'POST', + path: 'backup', + body: { backup_reserve_percent: param('backup_reserve_percent') }, + }), + setOperationMode: (param) => ({ + method: 'POST', + path: 'operation', + body: { default_real_mode: param('default_real_mode') }, + }), + setStormMode: (param) => ({ method: 'POST', path: 'storm_mode', body: { enabled: param('enabled') } }), + gridImportExport: (param) => ({ + method: 'POST', + path: 'grid_import_export', + body: { + customer_preferred_export_rule: param('customer_preferred_export_rule'), + disallow_charge_from_grid_with_solar_installed: param('disallow_charge_from_grid_with_solar_installed'), + }, + }), + setOffGridVehicleChargingReserve: (param) => ({ + method: 'POST', + path: 'off_grid_vehicle_charging_reserve', + body: { off_grid_vehicle_charging_reserve_percent: param('off_grid_vehicle_charging_reserve_percent') }, + }), +}; export class TeslemetryEnergy implements INodeType { description: INodeTypeDescription = { displayName: 'Teslemetry Energy', name: 'teslemetryEnergy', - icon: 'fa:bolt', + icon: 'file:teslemetryEnergy.svg', group: ['transform'], version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Interact with Teslemetry Energy API', defaults: { name: 'Teslemetry Energy', }, - inputs: ['main'], - outputs: ['main'], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + usableAsTool: true, credentials: [ { name: 'teslemetryApi', @@ -30,7 +70,7 @@ export class TeslemetryEnergy implements INodeType { ], properties: [ { - displayName: 'Site ID', + displayName: 'Site Name or ID', name: 'siteId', type: 'options', typeOptions: { @@ -38,13 +78,29 @@ export class TeslemetryEnergy implements INodeType { }, default: '', required: true, - description: 'Energy Site ID', + description: 'Energy Site ID. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Setting', value: 'settings', description: 'Backup reserve, operation mode, storm mode and grid rules' }, + { name: 'Status', value: 'status', description: 'Live status, site information and tariff' }, + ], + default: 'status', }, { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, + displayOptions: { + show: { + resource: ['status'], + }, + }, options: [ { name: 'Get Live Status', @@ -61,11 +117,35 @@ export class TeslemetryEnergy implements INodeType { value: 'getTariff', action: 'Get tariff TOU rates', }, + ], + default: 'getLiveStatus', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['settings'], + }, + }, + options: [ { name: 'Set Backup Reserve', value: 'setBackupReserve', action: 'Set backup reserve percentage', }, + { + name: 'Set Grid Import/Export', + value: 'gridImportExport', + action: 'Set grid import export rules', + }, + { + name: 'Set Off-Grid Charge Reserve', + value: 'setOffGridVehicleChargingReserve', + action: 'Set off grid vehicle charging reserve', + }, { name: 'Set Operation Mode', value: 'setOperationMode', @@ -76,18 +156,8 @@ export class TeslemetryEnergy implements INodeType { value: 'setStormMode', action: 'Set storm mode', }, - { - name: 'Set Grid Import/Export', - value: 'gridImportExport', - action: 'Set grid import/export rules', - }, - { - name: 'Set Off-Grid Charge Reserve', - value: 'setOffGridVehicleChargingReserve', - action: 'Set off-grid vehicle charging reserve', - }, ], - default: 'getLiveStatus', + default: 'setBackupReserve', }, // Params { @@ -174,15 +244,7 @@ export class TeslemetryEnergy implements INodeType { methods = { loadOptions: { async getSites(this: ILoadOptionsFunctions): Promise { - return loadWithTeslemetry(this, async (teslemetry) => { - const response = await teslemetry.api.getProducts(); - const products = response.response || []; - const sites = products.filter((p: any) => p.resource_type === "battery" || p.resource_type === "solar" || "energy_site_id" in p); - return sites.map((s: any) => ({ - name: `${s.site_name || s.energy_site_id} (${s.energy_site_id})`, - value: s.energy_site_id, - })); - }); + return loadOptions(this, () => siteOptions(this)); }, }, }; @@ -191,61 +253,44 @@ export class TeslemetryEnergy implements INodeType { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; - const credentials = await this.getCredentials('teslemetryApi'); - const teslemetry = createTeslemetry(credentials.accessToken as string, this.logger); - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const operation = this.getNodeParameter('operation', itemIndex) as string; - const siteId = this.getNodeParameter('siteId', itemIndex) as number; - - const energy = teslemetry.api.getEnergySite(siteId); - - let result; - try { - switch (operation) { - case 'getLiveStatus': - result = await energy.getLiveStatus(); - break; - case 'getSiteInfo': - result = await energy.getSiteInfo(); - break; - case 'getTariff': - result = { response: await energy.getTariff() }; - break; - case 'setBackupReserve': - const reserve = this.getNodeParameter('backup_reserve_percent', itemIndex) as number; - result = await energy.setBackupReserve(reserve); - break; - case 'setOperationMode': - const mode = this.getNodeParameter('default_real_mode', itemIndex) as any; - result = await energy.setOperationMode(mode); - break; - case 'setStormMode': - const enabled = this.getNodeParameter('enabled', itemIndex) as boolean; - result = await energy.setStormMode(enabled); - break; - case 'gridImportExport': - const rule = this.getNodeParameter('customer_preferred_export_rule', itemIndex) as any; - const disallow = this.getNodeParameter('disallow_charge_from_grid_with_solar_installed', itemIndex) as boolean; - result = await energy.gridImportExport(rule, disallow); - break; - case 'setOffGridVehicleChargingReserve': - const offGridReserve = this.getNodeParameter('off_grid_vehicle_charging_reserve_percent', itemIndex) as number; - result = await energy.setOffGridVehicleChargingReserve(offGridReserve); - break; - default: + const operation = this.getNodeParameter('operation', itemIndex) as string; + const siteId = this.getNodeParameter('siteId', itemIndex) as number; + const base = `/api/1/energy_sites/${siteId}/`; + + let result: IDataObject; + if (operation === 'getTariff') { + // The tariff has no endpoint of its own; it is part of site_info. + const { response } = await teslemetryRequest(this, 'GET', `${base}site_info`, { + itemIndex, + }); + result = { + response: { + tariff_id: response?.tariff_id, + tariff_content: response?.tariff_content, + tariff_content_v2: response?.tariff_content_v2, + }, + }; + } else { + const build = OPERATIONS[operation]; + if (!build) { throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`, { itemIndex }); + } + const { method, path, body } = build( + (name) => this.getNodeParameter(name, itemIndex) as IDataObject[string], + ); + result = await teslemetryRequest(this, method, `${base}${path}`, { body, itemIndex }); } - returnData.push({ json: result }); + returnData.push({ json: result, pairedItem: { item: itemIndex } }); } catch (error: unknown) { if (this.continueOnFail()) { - returnData.push({ json: { error: errorText(error) } }); + returnData.push({ json: { error: errorText(error) }, pairedItem: { item: itemIndex } }); continue; } throw toNodeError(this.getNode(), error, itemIndex); } } - return this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/teslemetryEnergy.svg b/packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/teslemetryEnergy.svg new file mode 100644 index 0000000..1504488 --- /dev/null +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryEnergy/teslemetryEnergy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryVehicle.node.ts b/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts similarity index 61% rename from packages/n8n-nodes-teslemetry/src/nodes/TeslemetryVehicle.node.ts rename to packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts index f9f5ef2..a4626b4 100644 --- a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryVehicle.node.ts +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts @@ -1,13 +1,139 @@ import { + IDataObject, IExecuteFunctions, + IHttpRequestMethods, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription, + jsonParse, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; -import { commandFailure, createTeslemetry, errorText, loadWithTeslemetry, toNodeError } from '../shared'; +import { commandFailure, errorText, loadOptions, teslemetryRequest, toNodeError, vehicleOptions } from '../shared'; + +// The API numbers seats; the node offers their names. +const FRONT_SEATS: Record = { front_left: 1, front_right: 2 }; +const ALL_SEATS: Record = { + front_left: 0, + front_right: 1, + rear_left: 2, + rear_center: 4, + rear_right: 5, +}; + +/** Reads one node parameter of the item being processed. */ +type Param = (name: string) => IDataObject[string]; +type VehicleRequest = { method: IHttpRequestMethods; path: string; body?: IDataObject; qs?: IDataObject }; + +/** A signed vehicle command at command/, with an optional JSON body. */ +const command = + (name: string, body?: (param: Param) => IDataObject) => + (param: Param): VehicleRequest => ({ method: 'POST', path: `command/${name}`, body: body?.(param) }); + +/** A Teslemetry custom command at custom_command/. */ +const customCommand = + (name: string, body: (param: Param) => IDataObject) => + (param: Param): VehicleRequest => ({ method: 'POST', path: `custom_command/${name}`, body: body(param) }); + +/** A JSON parameter, which n8n hands over as text or, from an expression, already parsed. */ +const jsonParam = (param: Param, name: string): IDataObject => { + const value = param(name); + return typeof value === 'string' ? jsonParse(value) : (value as IDataObject); +}; + +// Each operation's request, relative to /api/1/vehicles/{vin}/. +const OPERATIONS: Record VehicleRequest> = { + vehicleData: () => ({ method: 'GET', path: 'vehicle_data', qs: { use_cache: true } }), + wakeUp: () => ({ method: 'POST', path: 'wake_up' }), + flashLights: command('flash_lights'), + honkHorn: command('honk_horn'), + lockDoors: command('door_lock'), + unlockDoors: command('door_unlock'), + remoteStart: command('remote_start_drive'), + actuateTrunk: command('actuate_trunk', (param) => ({ which_trunk: param('which_trunk') })), + startAutoConditioning: command('auto_conditioning_start'), + stopAutoConditioning: command('auto_conditioning_stop'), + setTemps: command('set_temps', (param) => ({ + driver_temp: param('driver_temp'), + passenger_temp: param('passenger_temp'), + })), + setSeatHeater: command('remote_seat_heater_request', (param) => ({ + heater: ALL_SEATS[param('heater') as string], + level: param('level'), + })), + setSteeringWheelHeater: command('remote_steering_wheel_heater_request', (param) => ({ on: param('on') })), + startCharging: command('charge_start'), + stopCharging: command('charge_stop'), + openChargePort: command('charge_port_door_open'), + closeChargePort: command('charge_port_door_close'), + setChargeLimit: command('set_charge_limit', (param) => ({ percent: param('percent') })), + setChargingAmps: command('set_charging_amps', (param) => ({ charging_amps: param('charging_amps') })), + setSentryMode: command('set_sentry_mode', (param) => ({ on: param('on') })), + triggerHomelink: command('trigger_homelink', (param) => ({ lat: param('lat'), lon: param('lon') })), + navigationRequest: command('navigation_request', (param) => ({ value: param('value') })), + setSeatCooler: command('remote_seat_cooler_request', (param) => ({ + seat_position: FRONT_SEATS[param('front_seat_position') as string], + seat_cooler_level: param('seat_cooler_level'), + })), + setAutoSeatClimate: command('remote_auto_seat_climate_request', (param) => ({ + auto_seat_position: FRONT_SEATS[param('front_seat_position') as string], + auto_climate_on: param('auto_climate_on'), + })), + setAutoSteeringWheelHeat: command('remote_auto_steering_wheel_heat_climate_request', (param) => ({ + on: param('auto_climate_on'), + })), + setSteeringWheelHeatLevel: command('remote_steering_wheel_heat_level_request', (param) => ({ + level: param('steering_wheel_heat_level'), + })), + setCabinOverheatProtection: command('set_cabin_overheat_protection', (param) => ({ + on: param('auto_climate_on'), + fan_only: param('fan_only'), + })), + setCopTemp: command('set_cop_temp', (param) => ({ cop_temp: param('cop_temp') })), + setClimateKeeperMode: command('set_climate_keeper_mode', (param) => ({ + climate_keeper_mode: param('climate_keeper_mode'), + })), + setBioweaponDefenseMode: command('set_bioweapon_mode', (param) => ({ + on: param('auto_climate_on'), + manual_override: param('manual_override'), + })), + setPreconditioningMax: command('set_preconditioning_max', (param) => ({ + on: param('auto_climate_on'), + manual_override: param('manual_override'), + })), + windowControl: command('window_control', (param) => ({ + command: param('window_command'), + lat: param('lat'), + lon: param('lon'), + })), + sunRoofControl: command('sun_roof_control', (param) => ({ state: param('sunroof_state') })), + tonneauControl: customCommand('closure', (param) => ({ tonneau: param('tonneau_command') })), + setScheduledCharging: command('set_scheduled_charging', (param) => { + const [hours, minutes] = (param('schedule_time') as string).split(':').map(Number); + return { enable: param('schedule_enable'), time: hours * 60 + minutes }; + }), + setScheduledDeparture: command('set_scheduled_departure', (param) => + jsonParam(param, 'scheduled_departure_body'), + ), + addChargeSchedule: command('add_charge_schedule', (param) => jsonParam(param, 'charge_schedule_body')), + removeChargeSchedule: command('remove_charge_schedule', (param) => ({ id: param('schedule_id') })), + addPreconditionSchedule: command('add_precondition_schedule', (param) => + jsonParam(param, 'precondition_schedule_body'), + ), + removePreconditionSchedule: command('remove_precondition_schedule', (param) => ({ id: param('schedule_id') })), + chargeOnSolar: customCommand('charge_on_solar', (param) => ({ + enabled: param('solar_charging_enabled'), + lowerChargeLimit: param('solar_lower_charge_limit'), + upperChargeLimit: param('solar_upper_charge_limit'), + })), + chargeStandard: command('charge_standard'), + chargeMaxRange: command('charge_max_range'), + scheduleSoftwareUpdate: command('schedule_software_update', (param) => ({ offset_sec: param('offset_sec') })), + cancelSoftwareUpdate: command('cancel_software_update'), + adjustVolume: command('adjust_volume', (param) => ({ volume: param('volume') })), +}; export class TeslemetryVehicle implements INodeType { description: INodeTypeDescription = { @@ -16,12 +142,14 @@ export class TeslemetryVehicle implements INodeType { icon: 'file:teslemetryVehicle.svg', group: ['transform'], version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Interact with Teslemetry Vehicle API', defaults: { name: 'Teslemetry Vehicle', }, - inputs: ['main'], - outputs: ['main'], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + usableAsTool: true, credentials: [ { name: 'teslemetryApi', @@ -30,7 +158,7 @@ export class TeslemetryVehicle implements INodeType { ], properties: [ { - displayName: 'VIN', + displayName: 'VIN Name or ID', name: 'vin', type: 'options', typeOptions: { @@ -38,43 +166,56 @@ export class TeslemetryVehicle implements INodeType { }, default: '', required: true, - description: 'Vehicle Identification Number (VIN)', + description: 'Vehicle Identification Number (VIN). Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Charging', value: 'charging', description: 'Charge port, limits, amps and charge schedules' }, + { name: 'Climate', value: 'climate', description: 'Cabin climate, seats, steering wheel and preconditioning' }, + { name: 'Doors and Closure', value: 'closures', description: 'Locks, trunks, windows, sunroof and tonneau' }, + { name: 'Vehicle', value: 'vehicle', description: 'Read data, wake, locate and other general commands' }, + ], + default: 'vehicle', }, { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, - options: [ - { - name: 'Get Vehicle Data', - value: 'vehicleData', - action: 'Get vehicle data', + displayOptions: { + show: { + resource: ['vehicle'], }, + }, + options: [ { - name: 'Wake Up', - value: 'wakeUp', - action: 'Wake up the vehicle', + name: 'Cancel Software Update', + value: 'cancelSoftwareUpdate', + action: 'Cancel a scheduled software update', }, { name: 'Flash Lights', value: 'flashLights', action: 'Flash the lights', }, + { + name: 'Get Vehicle Data', + value: 'vehicleData', + action: 'Get vehicle data', + }, { name: 'Honk Horn', value: 'honkHorn', action: 'Honk the horn', }, { - name: 'Lock Doors', - value: 'lockDoors', - action: 'Lock the doors', - }, - { - name: 'Unlock Doors', - value: 'unlockDoors', - action: 'Unlock the doors', + name: 'Navigation Request', + value: 'navigationRequest', + action: 'Send a navigation request to the vehicle', }, { name: 'Remote Start', @@ -82,84 +223,97 @@ export class TeslemetryVehicle implements INodeType { action: 'Remote start the vehicle', }, { - name: 'Actuate Trunk', - value: 'actuateTrunk', - action: 'Open/Close trunk', - }, - { - name: 'Start HVAC', - value: 'startAutoConditioning', - action: 'Start auto conditioning', + name: 'Schedule Software Update', + value: 'scheduleSoftwareUpdate', + action: 'Schedule a software update install', }, { - name: 'Stop HVAC', - value: 'stopAutoConditioning', - action: 'Stop auto conditioning', + name: 'Set Sentry Mode', + value: 'setSentryMode', + action: 'Enable or disable sentry mode', }, { - name: 'Set Temperatures', - value: 'setTemps', - action: 'Set driver and passenger temperatures', + name: 'Set Volume', + value: 'adjustVolume', + action: 'Set the absolute media volume', }, { - name: 'Set Seat Heater', - value: 'setSeatHeater', - action: 'Set seat heater level', + name: 'Trigger Homelink', + value: 'triggerHomelink', + action: 'Trigger homelink', }, { - name: 'Set Steering Wheel Heater', - value: 'setSteeringWheelHeater', - action: 'Set steering wheel heater', + name: 'Wake Up', + value: 'wakeUp', + action: 'Wake up the vehicle', }, - { - name: 'Start Charging', - value: 'startCharging', - action: 'Start charging', + ], + default: 'vehicleData', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['closures'], }, + }, + options: [ { - name: 'Stop Charging', - value: 'stopCharging', - action: 'Stop charging', + name: 'Actuate Trunk', + value: 'actuateTrunk', + action: 'Open or close the trunk', }, { - name: 'Open Charge Port', - value: 'openChargePort', - action: 'Open charge port', + name: 'Control Sunroof', + value: 'sunRoofControl', + action: 'Vent close or stop the sunroof', }, { - name: 'Close Charge Port', - value: 'closeChargePort', - action: 'Close charge port', + name: 'Control Tonneau', + value: 'tonneauControl', + action: 'Open or close the tonneau', }, { - name: 'Set Charge Limit', - value: 'setChargeLimit', - action: 'Set charge limit percentage', + name: 'Control Windows', + value: 'windowControl', + action: 'Vent or close the windows', }, { - name: 'Set Charging Amps', - value: 'setChargingAmps', - action: 'Set charging amps', + name: 'Lock Doors', + value: 'lockDoors', + action: 'Lock the doors', }, { - name: 'Set Sentry Mode', - value: 'setSentryMode', - action: 'Enable or disable sentry mode', + name: 'Unlock Doors', + value: 'unlockDoors', + action: 'Unlock the doors', }, - { - name: 'Trigger Homelink', - value: 'triggerHomelink', - action: 'Trigger homelink', + ], + default: 'lockDoors', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['climate'], }, + }, + options: [ { - name: 'Navigation Request', - value: 'navigationRequest', - action: 'Send a navigation request to the vehicle', + name: 'Add Precondition Schedule', + value: 'addPreconditionSchedule', + action: 'Add or update a repeating precondition schedule', }, { - name: 'Set Seat Cooler', - value: 'setSeatCooler', - action: 'Set front seat cooler level', + name: 'Remove Precondition Schedule', + value: 'removePreconditionSchedule', + action: 'Remove a precondition schedule', }, { name: 'Set Auto Seat Climate', @@ -172,9 +326,9 @@ export class TeslemetryVehicle implements INodeType { action: 'Enable or disable automatic steering wheel heat', }, { - name: 'Set Steering Wheel Heat Level', - value: 'setSteeringWheelHeatLevel', - action: 'Set steering wheel heat level', + name: 'Set Bioweapon Defense Mode', + value: 'setBioweaponDefenseMode', + action: 'Enable or disable bioweapon defense mode', }, { name: 'Set Cabin Overheat Protection', @@ -191,93 +345,127 @@ export class TeslemetryVehicle implements INodeType { value: 'setClimateKeeperMode', action: 'Set climate keeper mode', }, - { - name: 'Set Bioweapon Defense Mode', - value: 'setBioweaponDefenseMode', - action: 'Enable or disable bioweapon defense mode', - }, { name: 'Set Preconditioning Max', value: 'setPreconditioningMax', action: 'Enable or disable maximum power preconditioning', }, { - name: 'Control Windows', - value: 'windowControl', - action: 'Vent or close the windows', + name: 'Set Seat Cooler', + value: 'setSeatCooler', + action: 'Set front seat cooler level', }, { - name: 'Control Sunroof', - value: 'sunRoofControl', - action: 'Vent, close, or stop the sunroof', + name: 'Set Seat Heater', + value: 'setSeatHeater', + action: 'Set seat heater level', }, { - name: 'Control Tonneau', - value: 'tonneauControl', - action: 'Open or close the Cybertruck tonneau', + name: 'Set Steering Wheel Heat Level', + value: 'setSteeringWheelHeatLevel', + action: 'Set steering wheel heat level', }, { - name: 'Set Scheduled Charging', - value: 'setScheduledCharging', - action: 'Enable or disable scheduled charging', + name: 'Set Steering Wheel Heater', + value: 'setSteeringWheelHeater', + action: 'Set steering wheel heater', }, { - name: 'Set Scheduled Departure', - value: 'setScheduledDeparture', - action: 'Set scheduled departure and off-peak charging', + name: 'Set Temperatures', + value: 'setTemps', + action: 'Set driver and passenger temperatures', }, { - name: 'Add Charge Schedule', - value: 'addChargeSchedule', - action: 'Add or update a repeating charge schedule', + name: 'Start HVAC', + value: 'startAutoConditioning', + action: 'Start auto conditioning', }, { - name: 'Remove Charge Schedule', - value: 'removeChargeSchedule', - action: 'Remove a charge schedule', + name: 'Stop HVAC', + value: 'stopAutoConditioning', + action: 'Stop auto conditioning', }, - { - name: 'Add Precondition Schedule', - value: 'addPreconditionSchedule', - action: 'Add or update a repeating precondition schedule', + ], + default: 'startAutoConditioning', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['charging'], }, + }, + options: [ { - name: 'Remove Precondition Schedule', - value: 'removePreconditionSchedule', - action: 'Remove a precondition schedule', + name: 'Add Charge Schedule', + value: 'addChargeSchedule', + action: 'Add or update a repeating charge schedule', }, { name: 'Charge on Solar', value: 'chargeOnSolar', action: 'Enable or disable charging on solar with limits', }, + { + name: 'Charge to Max Range', + value: 'chargeMaxRange', + action: 'Set the charge limit to max range', + }, { name: 'Charge to Standard', value: 'chargeStandard', action: 'Set the charge limit to standard', }, { - name: 'Charge to Max Range', - value: 'chargeMaxRange', - action: 'Set the charge limit to max range', + name: 'Close Charge Port', + value: 'closeChargePort', + action: 'Close charge port', }, { - name: 'Schedule Software Update', - value: 'scheduleSoftwareUpdate', - action: 'Schedule a software update install', + name: 'Open Charge Port', + value: 'openChargePort', + action: 'Open charge port', }, { - name: 'Cancel Software Update', - value: 'cancelSoftwareUpdate', - action: 'Cancel a scheduled software update', + name: 'Remove Charge Schedule', + value: 'removeChargeSchedule', + action: 'Remove a charge schedule', }, { - name: 'Set Volume', - value: 'adjustVolume', - action: 'Set the absolute media volume', + name: 'Set Charge Limit', + value: 'setChargeLimit', + action: 'Set charge limit percentage', + }, + { + name: 'Set Charging Amps', + value: 'setChargingAmps', + action: 'Set charging amps', + }, + { + name: 'Set Scheduled Charging', + value: 'setScheduledCharging', + action: 'Enable or disable scheduled charging', + }, + { + name: 'Set Scheduled Departure', + value: 'setScheduledDeparture', + action: 'Set scheduled departure and off peak charging', + }, + { + name: 'Start Charging', + value: 'startCharging', + action: 'Start charging', + }, + { + name: 'Stop Charging', + value: 'stopCharging', + action: 'Stop charging', }, ], - default: 'vehicleData', + default: 'startCharging', }, // Operation parameters { @@ -324,8 +512,8 @@ export class TeslemetryVehicle implements INodeType { options: [ { name: 'Front Left', value: 'front_left' }, { name: 'Front Right', value: 'front_right' }, - { name: 'Rear Left', value: 'rear_left' }, { name: 'Rear Center', value: 'rear_center' }, + { name: 'Rear Left', value: 'rear_left' }, { name: 'Rear Right', value: 'rear_right' }, ], default: 'front_left', @@ -598,7 +786,7 @@ export class TeslemetryVehicle implements INodeType { }, }, { - displayName: 'Charging Time (HH:mm)', + displayName: 'Charging Time (HH:Mm)', name: 'schedule_time', type: 'string', default: '00:00', @@ -627,8 +815,7 @@ export class TeslemetryVehicle implements INodeType { type: 'json', default: '{\n "enable": true,\n "departure_time": 480,\n "preconditioning_enabled": true,\n "off_peak_charging_enabled": true\n}', - description: - 'Body for setScheduledDeparture. departure_time/end_off_peak_time are minutes into the day (1:05 AM = 65)', + description: 'Body for setScheduledDeparture. departure_time/end_off_peak_time are minutes into the day (1:05 AM = 65).', displayOptions: { show: { operation: ['setScheduledDeparture'], @@ -641,7 +828,7 @@ export class TeslemetryVehicle implements INodeType { type: 'json', default: '{\n "days_of_week": "All",\n "enabled": true,\n "start_enabled": true,\n "start_time": 60,\n "end_enabled": false,\n "lat": 0,\n "lon": 0\n}', - description: 'Body for addChargeSchedule. start_time/end_time are minutes into the day (1:05 AM = 65)', + description: 'Body for addChargeSchedule. start_time/end_time are minutes into the day (1:05 AM = 65).', displayOptions: { show: { operation: ['addChargeSchedule'], @@ -654,7 +841,7 @@ export class TeslemetryVehicle implements INodeType { type: 'json', default: '{\n "days_of_week": "All",\n "enabled": true,\n "precondition_time": 420,\n "lat": 0,\n "lon": 0\n}', - description: 'Body for addPreconditionSchedule. precondition_time is minutes into the day (1:05 AM = 65)', + description: 'Body for addPreconditionSchedule. precondition_time is minutes into the day (1:05 AM = 65).', displayOptions: { show: { operation: ['addPreconditionSchedule'], @@ -727,15 +914,7 @@ export class TeslemetryVehicle implements INodeType { methods = { loadOptions: { async getVins(this: ILoadOptionsFunctions): Promise { - return loadWithTeslemetry(this, async (teslemetry) => { - const response = await teslemetry.api.getVehicles(); - // The API returns { response: [...], count: ... } - const vehicles = response.response || []; - return vehicles.map((v: any) => ({ - name: `${v.display_name} (${v.vin})`, - value: v.vin, - })); - }); + return loadOptions(this, () => vehicleOptions(this)); }, }, }; @@ -744,245 +923,35 @@ export class TeslemetryVehicle implements INodeType { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; - const credentials = await this.getCredentials('teslemetryApi'); - const teslemetry = createTeslemetry(credentials.accessToken as string, this.logger); - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const operation = this.getNodeParameter('operation', itemIndex) as string; - const vin = this.getNodeParameter('vin', itemIndex) as string; - - const vehicle = teslemetry.getVehicle(vin).api; - - let result; - try { - switch (operation) { - case 'vehicleData': - result = await vehicle.vehicleData(); - break; - case 'wakeUp': - result = await vehicle.wakeUp(); - break; - case 'flashLights': - result = await vehicle.flashLights(); - break; - case 'honkHorn': - result = await vehicle.honkHorn(); - break; - case 'lockDoors': - result = await vehicle.lockDoors(); - break; - case 'unlockDoors': - result = await vehicle.unlockDoors(); - break; - case 'remoteStart': - result = await vehicle.remoteStart(); - break; - case 'actuateTrunk': - const whichTrunk = this.getNodeParameter('which_trunk', itemIndex) as "front" | "rear"; - result = await vehicle.actuateTrunk(whichTrunk); - break; - case 'startAutoConditioning': - result = await vehicle.startAutoConditioning(); - break; - case 'stopAutoConditioning': - result = await vehicle.stopAutoConditioning(); - break; - case 'setTemps': - const driverTemp = this.getNodeParameter('driver_temp', itemIndex) as number; - const passengerTemp = this.getNodeParameter('passenger_temp', itemIndex) as number; - result = await vehicle.setTemps(driverTemp, passengerTemp); - break; - case 'setSeatHeater': - const heater = this.getNodeParameter('heater', itemIndex) as any; - const level = this.getNodeParameter('level', itemIndex) as number; - result = await vehicle.setSeatHeater(heater, level); - break; - case 'setSteeringWheelHeater': - const swOn = this.getNodeParameter('on', itemIndex) as boolean; - result = await vehicle.setSteeringWheelHeater(swOn); - break; - case 'startCharging': - result = await vehicle.startCharging(); - break; - case 'stopCharging': - result = await vehicle.stopCharging(); - break; - case 'openChargePort': - result = await vehicle.openChargePort(); - break; - case 'closeChargePort': - result = await vehicle.closeChargePort(); - break; - case 'setChargeLimit': - const limit = this.getNodeParameter('percent', itemIndex) as number; - result = await vehicle.setChargeLimit(limit); - break; - case 'setChargingAmps': - const amps = this.getNodeParameter('charging_amps', itemIndex) as number; - result = await vehicle.setChargingAmps(amps); - break; - case 'setSentryMode': - const sentryOn = this.getNodeParameter('on', itemIndex) as boolean; - result = await vehicle.setSentryMode(sentryOn); - break; - case 'triggerHomelink': - const lat = this.getNodeParameter('lat', itemIndex) as number; - const lon = this.getNodeParameter('lon', itemIndex) as number; - result = await vehicle.triggerHomelink(lat, lon); - break; - case 'navigationRequest': - const value = this.getNodeParameter('value', itemIndex) as string; - result = await vehicle.navigationRequest({ value }); - break; - case 'setSeatCooler': { - const seatPosition = this.getNodeParameter('front_seat_position', itemIndex) as 'front_left' | 'front_right'; - const coolerLevel = this.getNodeParameter('seat_cooler_level', itemIndex) as number; - result = await vehicle.setSeatCooler(seatPosition, coolerLevel); - break; - } - case 'setAutoSeatClimate': { - const seatPosition = this.getNodeParameter('front_seat_position', itemIndex) as 'front_left' | 'front_right'; - const autoClimateOn = this.getNodeParameter('auto_climate_on', itemIndex) as boolean; - result = await vehicle.setAutoSeatClimate(seatPosition, autoClimateOn); - break; - } - case 'setAutoSteeringWheelHeat': { - const autoHeatOn = this.getNodeParameter('auto_climate_on', itemIndex) as boolean; - result = await vehicle.setAutoSteeringWheelHeat(autoHeatOn); - break; - } - case 'setSteeringWheelHeatLevel': { - const heatLevel = this.getNodeParameter('steering_wheel_heat_level', itemIndex) as 0 | 1 | 3; - result = await vehicle.setSteeringWheelHeatLevel(heatLevel); - break; - } - case 'setCabinOverheatProtection': { - const copOn = this.getNodeParameter('auto_climate_on', itemIndex) as boolean; - const fanOnly = this.getNodeParameter('fan_only', itemIndex) as boolean; - result = await vehicle.setCabinOverheatProtection({ on: copOn, fan_only: fanOnly }); - break; - } - case 'setCopTemp': { - const copTemp = this.getNodeParameter('cop_temp', itemIndex) as 0 | 1 | 2; - result = await vehicle.setCopTemp(copTemp); - break; - } - case 'setClimateKeeperMode': { - const keeperMode = this.getNodeParameter('climate_keeper_mode', itemIndex) as 0 | 1 | 2 | 3; - result = await vehicle.setClimateKeeperMode(keeperMode); - break; - } - case 'setBioweaponDefenseMode': { - const bioweaponOn = this.getNodeParameter('auto_climate_on', itemIndex) as boolean; - const bioweaponManualOverride = this.getNodeParameter('manual_override', itemIndex) as boolean; - result = await vehicle.setBioweaponDefenseMode(bioweaponOn, bioweaponManualOverride); - break; - } - case 'setPreconditioningMax': { - const preconditioningOn = this.getNodeParameter('auto_climate_on', itemIndex) as boolean; - const preconditioningManualOverride = this.getNodeParameter('manual_override', itemIndex) as boolean; - result = await vehicle.setPreconditioningMax(preconditioningOn, preconditioningManualOverride); - break; - } - case 'windowControl': { - const windowCommand = this.getNodeParameter('window_command', itemIndex) as 'vent' | 'close'; - const windowLat = this.getNodeParameter('lat', itemIndex) as number; - const windowLon = this.getNodeParameter('lon', itemIndex) as number; - result = await vehicle.windowControl(windowCommand, windowLat, windowLon); - break; - } - case 'sunRoofControl': { - const sunroofState = this.getNodeParameter('sunroof_state', itemIndex) as 'vent' | 'close' | 'stop'; - result = await vehicle.sunRoofControl(sunroofState); - break; - } - case 'tonneauControl': { - const tonneauCommand = this.getNodeParameter('tonneau_command', itemIndex) as 'open' | 'close'; - result = await vehicle.closure({ tonneau: tonneauCommand }); - break; - } - case 'setScheduledCharging': { - const scheduleEnable = this.getNodeParameter('schedule_enable', itemIndex) as boolean; - const scheduleTime = this.getNodeParameter('schedule_time', itemIndex) as string; - const [hours, minutes] = scheduleTime.split(':').map(Number); - result = await vehicle.setScheduledCharging(scheduleEnable, hours * 60 + minutes); - break; - } - case 'setScheduledDeparture': { - const departureBody = JSON.parse( - this.getNodeParameter('scheduled_departure_body', itemIndex) as string, - ); - result = await vehicle.setScheduledDeparture(departureBody); - break; - } - case 'addChargeSchedule': { - const chargeScheduleBody = JSON.parse( - this.getNodeParameter('charge_schedule_body', itemIndex) as string, - ); - result = await vehicle.addChargeSchedule(chargeScheduleBody); - break; - } - case 'removeChargeSchedule': { - const chargeScheduleId = this.getNodeParameter('schedule_id', itemIndex) as number; - result = await vehicle.removeChargeSchedule(chargeScheduleId); - break; - } - case 'addPreconditionSchedule': { - const preconditionScheduleBody = JSON.parse( - this.getNodeParameter('precondition_schedule_body', itemIndex) as string, - ); - result = await vehicle.addPreconditionSchedule(preconditionScheduleBody); - break; - } - case 'removePreconditionSchedule': { - const preconditionScheduleId = this.getNodeParameter('schedule_id', itemIndex) as number; - result = await vehicle.removePreconditionSchedule(preconditionScheduleId); - break; - } - case 'chargeOnSolar': { - const solarEnabled = this.getNodeParameter('solar_charging_enabled', itemIndex) as boolean; - const solarLower = this.getNodeParameter('solar_lower_charge_limit', itemIndex) as number; - const solarUpper = this.getNodeParameter('solar_upper_charge_limit', itemIndex) as number; - result = await vehicle.chargeOnSolar(solarEnabled, solarLower, solarUpper); - break; - } - case 'chargeStandard': - result = await vehicle.chargeStandard(); - break; - case 'chargeMaxRange': - result = await vehicle.chargeMaxRange(); - break; - case 'scheduleSoftwareUpdate': { - const offsetSec = this.getNodeParameter('offset_sec', itemIndex) as number; - result = await vehicle.scheduleSoftwareUpdate(offsetSec); - break; - } - case 'cancelSoftwareUpdate': - result = await vehicle.cancelSoftwareUpdate(); - break; - case 'adjustVolume': { - const volume = this.getNodeParameter('volume', itemIndex) as number; - result = await vehicle.adjustVolume(volume); - break; - } - default: - throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`, { itemIndex }); + const operation = this.getNodeParameter('operation', itemIndex) as string; + const vin = this.getNodeParameter('vin', itemIndex) as string; + const build = OPERATIONS[operation]; + if (!build) { + throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`, { itemIndex }); } + const { method, path, body, qs } = build( + (name) => this.getNodeParameter(name, itemIndex) as IDataObject[string], + ); + const result = await teslemetryRequest(this, method, `/api/1/vehicles/${vin}/${path}`, { + body, + qs, + itemIndex, + }); const failure = commandFailure(result); if (failure) { throw new NodeOperationError(this.getNode(), failure, { itemIndex }); } - returnData.push({ json: result }); - + returnData.push({ json: result, pairedItem: { item: itemIndex } }); } catch (error: unknown) { if (this.continueOnFail()) { - returnData.push({ json: { error: errorText(error) } }); + returnData.push({ json: { error: errorText(error) }, pairedItem: { item: itemIndex } }); continue; } throw toNodeError(this.getNode(), error, itemIndex); } } - return this.prepareOutputData(returnData); + return [returnData]; } } diff --git a/packages/n8n-nodes-teslemetry/src/nodes/teslemetryVehicle.svg b/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/teslemetryVehicle.svg similarity index 100% rename from packages/n8n-nodes-teslemetry/src/nodes/teslemetryVehicle.svg rename to packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/teslemetryVehicle.svg diff --git a/packages/n8n-nodes-teslemetry/nodes/shared.ts b/packages/n8n-nodes-teslemetry/nodes/shared.ts new file mode 100644 index 0000000..2da7d6d --- /dev/null +++ b/packages/n8n-nodes-teslemetry/nodes/shared.ts @@ -0,0 +1,143 @@ +import { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + INode, + IPollFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +export const CREDENTIAL_TYPE = 'teslemetryApi'; +export const BASE_URL = 'https://api.teslemetry.com'; + +type TeslemetryContext = IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions; + +/** + * The error n8n shows for a failed request, built from the API's failure + * answer: the `{ response: null, error: "", error_description: "" }` + * envelope, or a bare string for a non-JSON answer such as a proxy's error page. + * Only the text and status are kept, never the request, so the credential + * cannot reach the error. + */ +function apiError(node: INode, body: unknown, status: number, itemIndex?: number): NodeApiError { + let text: string | undefined; + let code: string | undefined; + if (typeof body === 'string') { + // A proxy's HTML error page is not worth showing; a short plain-text answer is. + const trimmed = body.trim(); + if (trimmed && trimmed.length <= 200 && !trimmed.includes('<')) text = trimmed; + } else if (body && typeof body === 'object') { + const envelope = body as { error?: unknown; error_description?: unknown }; + if (typeof envelope.error === 'string' && envelope.error) code = envelope.error; + text = + typeof envelope.error_description === 'string' && envelope.error_description + ? envelope.error_description + : code; + } + const message = text ?? `Teslemetry API request failed (HTTP ${status})`; + const details = [code === message ? undefined : code, `HTTP ${status}`].filter(Boolean).join(', '); + return new NodeApiError( + node, + { message }, + { message, description: `Teslemetry API answered: ${details}`, httpCode: String(status), itemIndex }, + ); +} + +/** + * Sends one request to the Teslemetry API with the node's credential, through + * n8n's own HTTP helper. A non-2xx answer throws a `NodeApiError` with the + * API's text and status, a request that got no answer one without a status. + */ +export async function teslemetryRequest( + context: TeslemetryContext, + method: IHttpRequestMethods, + path: string, + options: { body?: IDataObject; qs?: IDataObject; itemIndex?: number } = {}, +): Promise { + const request: IHttpRequestOptions = { + method, + baseURL: BASE_URL, + url: path, + headers: { 'X-Library': 'n8n-nodes-teslemetry' }, + json: true, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + }; + if (options.body) request.body = options.body; + if (options.qs) request.qs = options.qs; + + let response: { body: unknown; statusCode: number }; + try { + response = await context.helpers.httpRequestWithAuthentication.call(context, CREDENTIAL_TYPE, request); + } catch (error) { + const message = `Could not reach the Teslemetry API: ${errorText(error)}`; + throw new NodeApiError(context.getNode(), { message }, { message, itemIndex: options.itemIndex }); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw apiError(context.getNode(), response.body, response.statusCode, options.itemIndex); + } + return response.body as T; +} + +/** The text an error item or a log line should carry for a failure. */ +export function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Passes n8n's own errors through and wraps anything else in a `NodeOperationError`. */ +export function toNodeError(node: INode, error: unknown, itemIndex?: number): NodeApiError | NodeOperationError { + if (error instanceof NodeApiError || error instanceof NodeOperationError) return error; + return new NodeOperationError(node, error instanceof Error ? error : errorText(error), { itemIndex }); +} + +/** Runs a dropdown loader, surfacing failures with the API's text. */ +export async function loadOptions(context: ILoadOptionsFunctions, load: () => Promise): Promise { + try { + return await load(); + } catch (error) { + throw toNodeError(context.getNode(), error); + } +} + +type Vehicle = { vin: string; display_name?: string }; +type Product = { energy_site_id?: number; site_name?: string; resource_type?: string }; + +/** The account's vehicles as dropdown options. */ +export async function vehicleOptions(context: ILoadOptionsFunctions) { + const { response } = await teslemetryRequest<{ response?: Vehicle[] }>(context, 'GET', '/api/1/vehicles'); + return (response ?? []).map((vehicle) => ({ + name: `${vehicle.display_name} (${vehicle.vin})`, + value: vehicle.vin, + })); +} + +/** The account's energy sites as dropdown options. */ +export async function siteOptions(context: ILoadOptionsFunctions) { + const { response } = await teslemetryRequest<{ response?: Product[] }>(context, 'GET', '/api/1/products'); + return (response ?? []) + .filter( + (product): product is Product & { energy_site_id: number } => + product.resource_type === 'battery' || product.resource_type === 'solar' || 'energy_site_id' in product, + ) + .map((site) => ({ + name: `${site.site_name || site.energy_site_id} (${site.energy_site_id})`, + value: site.energy_site_id, + })); +} + +// A command Tesla or the car did not carry out still answers HTTP 200, with +// `result: false` and a reason. These reasons mean the car is already in the +// requested state, so they are not failures. +const BENIGN_COMMAND_REASONS = ['already_set', 'not_charging', 'requested']; + +/** The failure text for a command answered with `result: false`, or undefined when it succeeded. */ +export function commandFailure(result: unknown): string | undefined { + const response = (result as { response?: { result?: unknown; reason?: unknown } } | null | undefined)?.response; + if (!response || typeof response !== 'object' || response.result !== false) return undefined; + const reason = typeof response.reason === 'string' ? response.reason : ''; + if (BENIGN_COMMAND_REASONS.includes(reason)) return undefined; + return reason ? `Command failed: ${reason}` : 'Command failed with no reason given'; +} diff --git a/packages/n8n-nodes-teslemetry/package.json b/packages/n8n-nodes-teslemetry/package.json index 56c2672..af20654 100644 --- a/packages/n8n-nodes-teslemetry/package.json +++ b/packages/n8n-nodes-teslemetry/package.json @@ -7,7 +7,7 @@ }, "scripts": { "test": "tsx --test test/*.test.ts", - "build": "tsdown && cp src/nodes/*.svg dist/nodes/", + "build": "tsdown && for dir in nodes/*/ credentials/; do cp \"$dir\"*.svg \"dist/$dir\"; done", "verify:package": "tsx scripts/verify-package.ts", "dev2": "pnpm build && mkdir -p .n8n-home/nodes && ln -sf $(pwd) .n8n-home/nodes/teslemetry && N8N_LOG_OUTPUT=console N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true N8N_USER_FOLDER=$(pwd)/.n8n-home ./node_modules/.bin/n8n start", "tsc": "tsc", @@ -50,9 +50,9 @@ "dist/credentials/TeslemetryApi.credentials.js" ], "nodes": [ - "dist/nodes/TeslemetryVehicle.node.js", - "dist/nodes/TeslemetryEnergy.node.js", - "dist/nodes/TeslemetryTrigger.node.js" + "dist/nodes/TeslemetryVehicle/TeslemetryVehicle.node.js", + "dist/nodes/TeslemetryEnergy/TeslemetryEnergy.node.js", + "dist/nodes/TeslemetryTrigger/TeslemetryTrigger.node.js" ] }, "peerDependencies": { @@ -60,7 +60,6 @@ }, "devDependencies": { "@n8n/node-cli": "^0.51.5", - "@teslemetry/api": "workspace:*", "eslint": "9.29.0", "n8n": "^1.122.5", "n8n-workflow": "^1.119.1", diff --git a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryTrigger.node.ts b/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryTrigger.node.ts deleted file mode 100644 index 753acd6..0000000 --- a/packages/n8n-nodes-teslemetry/src/nodes/TeslemetryTrigger.node.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { SseEvent } from '@teslemetry/api'; -import { - ILoadOptionsFunctions, - INodePropertyOptions, - INodeType, - INodeTypeDescription, - ITriggerFunctions, - ITriggerResponse, - NodeOperationError, -} from 'n8n-workflow'; -import { createTeslemetry, errorText, loadWithTeslemetry, TeslemetryRequestError, toNodeError } from '../shared'; - -// Answers the user has to act on, by status. 429 is here because the API -// answers repeated bad-token attempts with it, and because the stream caps the -// number of connections per account. -const STREAM_REFUSALS: Record = { - 401: 'Teslemetry rejected the access token', - 403: 'Teslemetry rejected the access token', - 402: 'Teslemetry requires an active subscription', - 429: 'Teslemetry is limiting requests or stream connections for this account', -}; - -export class TeslemetryTrigger implements INodeType { - description: INodeTypeDescription = { - displayName: 'Teslemetry Trigger', - name: 'teslemetryTrigger', - icon: 'fa:rss', - group: ['trigger'], - version: 1, - description: 'Starts the workflow when a Teslemetry event occurs', - defaults: { - name: 'Teslemetry Trigger', - }, - inputs: [], - outputs: ['main'], - credentials: [ - { - name: 'teslemetryApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - options: [ - { name: 'Vehicle', value: 'vehicle' }, - { name: 'Energy Site', value: 'energySite' }, - ], - default: 'vehicle', - }, - { - displayName: 'VIN', - name: 'vin', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getVins', - }, - default: '', - description: 'Vehicle to monitor. Leave empty for all vehicles (where applicable).', - displayOptions: { - show: { - resource: ['vehicle'], - }, - }, - }, - { - displayName: 'Site ID', - name: 'siteId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getSites', - }, - default: '', - description: 'Energy site to monitor. Leave empty for all sites.', - displayOptions: { - show: { - resource: ['energySite'], - }, - }, - }, - { - displayName: 'Event Type', - name: 'event', - type: 'options', - options: [ - { name: 'All Events', value: 'all' }, - { name: 'Data', value: 'data' }, - { name: 'State', value: 'state' }, - { name: 'Vehicle Data', value: 'vehicle_data' }, - { name: 'Errors', value: 'errors' }, - { name: 'Alerts', value: 'alerts' }, - { name: 'Connectivity', value: 'connectivity' }, - { name: 'Credits', value: 'credits' }, - { name: 'Config', value: 'config' }, - { name: 'Signal', value: 'signal' }, - ], - default: 'all', - required: true, - displayOptions: { - show: { - resource: ['vehicle'], - }, - }, - }, - { - displayName: 'Event Type', - name: 'event', - type: 'options', - options: [ - { name: 'Live Status', value: 'live_status' }, - { name: 'Site Info', value: 'site_info' }, - { name: 'Tariff Content', value: 'tariff_content_v2' }, - { name: 'Energy Totals', value: 'energy_totals' }, - ], - default: 'live_status', - required: true, - displayOptions: { - show: { - resource: ['energySite'], - }, - }, - }, - { - displayName: 'Signal Field', - name: 'field', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getFields', - }, - default: '', - displayOptions: { - show: { - resource: ['vehicle'], - event: ['signal'], - }, - }, - description: 'The specific signal field to listen for', - }, - ], - }; - - methods = { - loadOptions: { - async getVins(this: ILoadOptionsFunctions): Promise { - return loadWithTeslemetry(this, async (teslemetry) => { - const response = await teslemetry.api.getVehicles(); - const vehicles = response.response || []; - return [ - { name: 'All Vehicles', value: '' }, - ...vehicles.map((v: any) => ({ - name: `${v.display_name} (${v.vin})`, - value: v.vin, - })), - ]; - }); - }, - async getSites(this: ILoadOptionsFunctions): Promise { - return loadWithTeslemetry(this, async (teslemetry) => { - const response = await teslemetry.api.getProducts(); - const products = response.response || []; - const sites = products.filter( - (p: any) => p.resource_type === 'battery' || p.resource_type === 'solar' || 'energy_site_id' in p, - ); - return [ - { name: 'All Sites', value: '' }, - ...sites.map((s: any) => ({ - name: `${s.site_name || s.energy_site_id} (${s.energy_site_id})`, - value: s.energy_site_id, - })), - ]; - }); - }, - async getFields(this: ILoadOptionsFunctions): Promise { - return loadWithTeslemetry(this, async (teslemetry) => { - const fields = await teslemetry.api.getFields(); - return Object.keys(fields).map((f) => ({ - name: f, - value: f, - })); - }); - }, - }, - }; - - async trigger(this: ITriggerFunctions): Promise { - const credentials = await this.getCredentials('teslemetryApi'); - const resource = this.getNodeParameter('resource') as string; - const event = this.getNodeParameter('event') as string; - - if (resource !== 'energySite' && event === 'signal') { - if (!this.getNodeParameter('vin')) { - throw new NodeOperationError(this.getNode(), 'VIN is required for Signal events'); - } - if (!this.getNodeParameter('field', '')) { - throw new NodeOperationError(this.getNode(), 'Field is required for Signal events'); - } - } - - const teslemetry = createTeslemetry(credentials.accessToken as string, this.logger); - const sse = teslemetry.sse; - - // connect() returns before the stream answers, so without this check a - // trigger with a bad token activates, fails at runtime, and is reactivated - // by n8n in a loop. Failing here makes activation itself fail, which n8n - // reports to the user and retries with its own backoff. The test endpoint - // has the stream's requirements: a valid token and an active subscription. - try { - await teslemetry.api.test(); - } catch (error) { - const refusal = error instanceof TeslemetryRequestError && error.status && STREAM_REFUSALS[error.status]; - if (refusal) { - throw toNodeError(this.getNode(), error, undefined, `${refusal}: ${errorText(error)}`); - } - // Anything else (an outage, a network drop) is left to the stream's own retries. - this.logger.warn(`Teslemetry check before connecting failed: ${errorText(error)}`); - } - - let cleanup: () => void; - - const emit = (data: any) => { - this.emit([this.helpers.returnJsonArray(data)]); - }; - - if (resource === 'energySite') { - const siteId = this.getNodeParameter('siteId') as string; - - // live_status/site_info/tariff_content_v2 carry `site_id`; energy_totals carries `id` instead. - const callback = (eventData: any) => { - const eventSiteId = 'site_id' in eventData ? eventData.site_id : eventData.id; - if (!siteId || String(eventSiteId) === String(siteId)) { - emit(eventData); - } - }; - - const eventType = event as 'live_status' | 'site_info' | 'tariff_content_v2' | 'energy_totals'; - sse.on(eventType, callback); - cleanup = () => sse.off(eventType, callback); - } else { - const vin = this.getNodeParameter('vin') as string; - const field = this.getNodeParameter('field', '') as string; - - if (event === 'signal') { - cleanup = sse - .getVehicle(vin) - .onSignal(field as any, (value: any) => { - emit({ field, value, topic: 'signal' }); - }); - } else { - // Create callback that filters by VIN if specified - const callback = (eventData: SseEvent) => { - if (!vin || ('vin' in eventData && eventData.vin === vin)) { - emit(eventData); - } - }; - - // Determine event type to listen for - const eventType = event as - | 'all' - | 'data' - | 'state' - | 'vehicle_data' - | 'errors' - | 'alerts' - | 'connectivity' - | 'credits' - | 'config'; - - sse.on(eventType, callback); - cleanup = () => sse.off(eventType, callback); - } - } - - // Registered before connect() so a terminal auth failure on the very first - // attempt still reaches emitError instead of racing an unattached stream. - let reportedRefusal: number | undefined; - const onStreamError = (payload: { error: unknown; status?: number; retries: number }) => { - const { status } = payload; - // A subscription that lapses or a connection cap reached while the - // stream is running: the SDK keeps retrying, so name it once instead of - // on every attempt. - if (status === 402 || status === 429) { - if (reportedRefusal === status) return; - reportedRefusal = status; - const error = new NodeOperationError(this.getNode(), `${STREAM_REFUSALS[status]} (HTTP ${status})`); - // The check in trigger() catches 402 but cannot see the stream's - // connection cap, so in an active workflow a 429 reported through - // emitError would be reactivated straight back into the same 429. - if (status === 402 || this.getMode() === 'manual') { - this.emitError(error); - } else { - this.logger.error(`${error.message}. The stream keeps retrying.`); - } - return; - } - this.logger.warn( - `Teslemetry stream error (attempt ${payload.retries}): ${String(payload.error)}`, - ); - }; - const onDisconnect = () => { - this.logger.warn('Teslemetry stream disconnected'); - }; - const onAuthFailure = (error: Error & { status?: number }) => { - const status = error.status ?? 401; - this.emitError(new NodeOperationError(this.getNode(), `${STREAM_REFUSALS[status]} (HTTP ${status})`)); - }; - sse.on('stream_error', onStreamError); - sse.on('disconnect', onDisconnect); - sse.on('auth_failure', onAuthFailure); - - sse.connect(); - - let closed = false; - async function closeFunction() { - if (closed) return; - closed = true; - if (cleanup) cleanup(); - sse.off('stream_error', onStreamError); - sse.off('disconnect', onDisconnect); - sse.off('auth_failure', onAuthFailure); - await sse.disconnect(); - } - - return { - closeFunction, - }; - } -} diff --git a/packages/n8n-nodes-teslemetry/src/shared.ts b/packages/n8n-nodes-teslemetry/src/shared.ts deleted file mode 100644 index 733d885..0000000 --- a/packages/n8n-nodes-teslemetry/src/shared.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Teslemetry } from '@teslemetry/api'; -import { - IExecuteFunctions, - ILoadOptionsFunctions, - INode, - NodeApiError, - NodeOperationError, -} from 'n8n-workflow'; - -type N8nLogger = IExecuteFunctions['logger']; - -/** A failed Teslemetry request, carrying the text to show the user. */ -export class TeslemetryRequestError extends Error { - constructor( - message: string, - readonly status?: number, - readonly code?: string, - ) { - super(message); - this.name = 'TeslemetryRequestError'; - } -} - -// Every request carries the access token as a query parameter, so a query -// string must never reach a log line or an error message. -function stripQueryStrings(text: string): string { - return text.replace(/\?[\w.~%-]+=[^\s"'<>)]*/g, ''); -} - -/** - * The generated client throws the response body as-is: the API's - * `{ response: null, error: "", error_description: "" }` envelope, - * a bare string for a non-JSON answer, or a fetch error when nothing answered. - * None of those carries the HTTP status, and the envelope has no `message`. - */ -function toRequestError(error: unknown, status?: number): TeslemetryRequestError { - if (error instanceof TeslemetryRequestError) return error; - let text: string | undefined; - let code: string | undefined; - if (error instanceof Error) { - const cause = (error.cause as { code?: unknown } | undefined)?.code; - text = status - ? error.message - : `Could not reach the Teslemetry API: ${error.message}${typeof cause === 'string' ? ` (${cause})` : ''}`; - } else if (typeof error === 'string') { - // A proxy's HTML error page is not worth showing; a short plain-text answer is. - const body = error.trim(); - if (body && body.length <= 200 && !body.includes('<')) text = body; - } else if (error && typeof error === 'object') { - const body = error as { error?: unknown; error_description?: unknown }; - if (typeof body.error === 'string' && body.error) code = body.error; - text = typeof body.error_description === 'string' && body.error_description ? body.error_description : code; - } - text ??= status ? `Teslemetry API request failed (HTTP ${status})` : 'Teslemetry API request failed'; - return new TeslemetryRequestError(stripQueryStrings(text), status, code); -} - -function formatLogArgument(argument: unknown): string { - if (typeof argument === 'string') return argument; - if (argument instanceof Error) return `${argument.name}: ${argument.message}`; - try { - return JSON.stringify(argument) ?? String(argument); - } catch { - return String(argument); - } -} - -/** - * Builds the SDK client for one execution. Its log lines go to n8n's logger - * (the SDK default is `console`, which n8n's log level does not filter), and - * its request failures are thrown as `TeslemetryRequestError`. - */ -export function createTeslemetry(accessToken: string, logger: N8nLogger): Teslemetry { - const forward = - (level: 'debug' | 'warn' | 'error') => - (...args: unknown[]) => - logger[level](stripQueryStrings(args.map(formatLogArgument).join(' '))); - const teslemetry = new Teslemetry(accessToken, { - logger: { - debug: forward('debug'), - // The SDK's info lines narrate every client and connection ("Started - // local cache"), which is per-execution noise at n8n's default level. - info: forward('debug'), - warn: forward('warn'), - error: forward('error'), - }, - }); - teslemetry.client.interceptors.error.use((error, response) => toRequestError(error, response?.status)); - return teslemetry; -} - -/** The text an error item or a log line should carry for a failure. */ -export function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * Wraps a failure in the error class n8n renders: an API failure becomes a - * `NodeApiError` with the API's own text and HTTP status, anything else (bad - * input, an unknown operation) a `NodeOperationError`. - */ -export function toNodeError( - node: INode, - error: unknown, - itemIndex?: number, - message = errorText(error), -): NodeApiError | NodeOperationError { - if (error instanceof NodeApiError || error instanceof NodeOperationError) return error; - if (error instanceof TeslemetryRequestError) { - const code = error.code === message ? undefined : error.code; - const details = [code, error.status && `HTTP ${error.status}`].filter(Boolean).join(', '); - return new NodeApiError( - node, - { message }, - { - message, - description: details ? `Teslemetry API answered: ${details}` : undefined, - httpCode: error.status?.toString(), - itemIndex, - }, - ); - } - return new NodeOperationError(node, error instanceof Error ? error : message, { itemIndex }); -} - -/** Runs a dropdown loader against the node's credential, surfacing failures with the API's text. */ -export async function loadWithTeslemetry( - context: ILoadOptionsFunctions, - load: (teslemetry: Teslemetry) => Promise, -): Promise { - const credentials = await context.getCredentials('teslemetryApi'); - const teslemetry = createTeslemetry(credentials.accessToken as string, context.logger); - try { - return await load(teslemetry); - } catch (error) { - throw toNodeError(context.getNode(), error); - } -} - -// A command Tesla or the car did not carry out still answers HTTP 200, with -// `result: false` and a reason. These reasons mean the car is already in the -// requested state, so they are not failures. -const BENIGN_COMMAND_REASONS = ['already_set', 'not_charging', 'requested']; - -/** The failure text for a command answered with `result: false`, or undefined when it succeeded. */ -export function commandFailure(result: unknown): string | undefined { - const response = (result as { response?: { result?: unknown; reason?: unknown } } | null | undefined)?.response; - if (!response || typeof response !== 'object' || response.result !== false) return undefined; - const reason = typeof response.reason === 'string' ? response.reason : ''; - if (BENIGN_COMMAND_REASONS.includes(reason)) return undefined; - return reason ? `Command failed: ${reason}` : 'Command failed with no reason given'; -} diff --git a/packages/n8n-nodes-teslemetry/test/credential.test.ts b/packages/n8n-nodes-teslemetry/test/credential.test.ts index 4d8e0b7..dadfd52 100644 --- a/packages/n8n-nodes-teslemetry/test/credential.test.ts +++ b/packages/n8n-nodes-teslemetry/test/credential.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; -import { TeslemetryApi } from "../src/credentials/TeslemetryApi.credentials"; +import { TeslemetryApi } from "../credentials/TeslemetryApi.credentials"; test("credential tests the token against /api/test with a bearer header", () => { const credential = new TeslemetryApi(); diff --git a/packages/n8n-nodes-teslemetry/test/energyExecute.test.ts b/packages/n8n-nodes-teslemetry/test/energyExecute.test.ts index 3c609d6..d35b7a7 100644 --- a/packages/n8n-nodes-teslemetry/test/energyExecute.test.ts +++ b/packages/n8n-nodes-teslemetry/test/energyExecute.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { TeslemetryEnergy } from "../src/nodes/TeslemetryEnergy.node.js"; +import { TeslemetryEnergy } from "../nodes/TeslemetryEnergy/TeslemetryEnergy.node.js"; import { API_FAILURES, assertApiFailure, diff --git a/packages/n8n-nodes-teslemetry/test/loadOptions.test.ts b/packages/n8n-nodes-teslemetry/test/loadOptions.test.ts index 3b4c805..f9253cf 100644 --- a/packages/n8n-nodes-teslemetry/test/loadOptions.test.ts +++ b/packages/n8n-nodes-teslemetry/test/loadOptions.test.ts @@ -1,29 +1,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { TeslemetryVehicle } from "../src/nodes/TeslemetryVehicle.node.js"; -import { TeslemetryEnergy } from "../src/nodes/TeslemetryEnergy.node.js"; -import { TeslemetryTrigger } from "../src/nodes/TeslemetryTrigger.node.js"; -import { API_FAILURES, assertApiFailure, fakeLogger, fakeNode } from "./testHelpers.js"; +import { TeslemetryVehicle } from "../nodes/TeslemetryVehicle/TeslemetryVehicle.node.js"; +import { TeslemetryEnergy } from "../nodes/TeslemetryEnergy/TeslemetryEnergy.node.js"; +import { TeslemetryTrigger } from "../nodes/TeslemetryTrigger/TeslemetryTrigger.node.js"; +import { API_FAILURES, assertApiFailure, fakeHelpers, fakeLogger, fakeNode, withMockedFetch } from "./testHelpers.js"; // Minimal ILoadOptionsFunctions stand-in for the loadOptions methods. const fakeLoadOptionsContext = { - getCredentials: async () => ({ accessToken: "token" }), getNode: () => fakeNode, logger: fakeLogger().logger, + helpers: fakeHelpers, } as never; -function withMockedFetch( - handler: (request: Request) => Promise | Response, - run: () => Promise, -): Promise { - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => - handler(new Request(input, init))) as typeof fetch; - return run().finally(() => { - globalThis.fetch = original; - }); -} - test("TeslemetryVehicle.getVins calls a real TeslemetryApi method (regression: api.vehicles() is not a function)", async () => { const node = new TeslemetryVehicle(); const options = await withMockedFetch( @@ -73,10 +61,7 @@ test("TeslemetryTrigger.getVins calls a real TeslemetryApi method (regression: a () => node.methods.loadOptions.getVins.call(fakeLoadOptionsContext), ); - assert.deepEqual(options, [ - { name: "All Vehicles", value: "" }, - { name: "My Tesla (5YJSA1E14FF000000)", value: "5YJSA1E14FF000000" }, - ]); + assert.deepEqual(options, [{ name: "My Tesla (5YJSA1E14FF000000)", value: "5YJSA1E14FF000000" }]); }); test("TeslemetryTrigger.getSites calls a real TeslemetryApi method (regression: api.products() is not a function)", async () => { @@ -94,10 +79,7 @@ test("TeslemetryTrigger.getSites calls a real TeslemetryApi method (regression: () => node.methods.loadOptions.getSites.call(fakeLoadOptionsContext), ); - assert.deepEqual(options, [ - { name: "All Sites", value: "" }, - { name: "Home (123)", value: 123 }, - ]); + assert.deepEqual(options, [{ name: "Home (123)", value: 123 }]); }); test("TeslemetryTrigger.getFields calls a real TeslemetryApi method (regression: api.fields() is not a function)", async () => { diff --git a/packages/n8n-nodes-teslemetry/test/testHelpers.ts b/packages/n8n-nodes-teslemetry/test/testHelpers.ts index 95ce4c5..8eecea0 100644 --- a/packages/n8n-nodes-teslemetry/test/testHelpers.ts +++ b/packages/n8n-nodes-teslemetry/test/testHelpers.ts @@ -1,22 +1,52 @@ import assert from "node:assert/strict"; +import type { IHttpRequestOptions } from "n8n-workflow"; -export function withMockedFetch( - handler: (request: Request) => Promise | Response, - run: () => Promise, -): Promise { - const original = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - const response = await handler(request); - // A real response carries its request URL, access token included. - Object.defineProperty(response, "url", { value: request.url }); - return response; - }) as typeof fetch; +type Handler = (request: Request) => Promise | Response; + +let currentHandler: Handler | undefined; + +/** Answers every request the fake contexts' n8n HTTP helper sends with `handler` while `run` runs. */ +export function withMockedFetch(handler: Handler, run: () => Promise): Promise { + currentHandler = handler; return run().finally(() => { - globalThis.fetch = original; + currentHandler = undefined; }); } +/** + * Stand-in for n8n's `httpRequestWithAuthentication`: applies the credential's + * bearer header, sends the request to the current handler as a fetch Request, + * and answers the way n8n's axios-based helper does - a JSON body parsed, any + * other body as text, and with `ignoreHttpStatusErrors` no throw on a failure status. + */ +export async function fakeHttpRequestWithAuthentication(credentialType: string, options: IHttpRequestOptions) { + assert.equal(credentialType, "teslemetryApi"); + if (!currentHandler) throw new Error("no request handler is mocked"); + const url = new URL(options.url, options.baseURL); + for (const [key, value] of Object.entries(options.qs ?? {})) url.searchParams.set(key, String(value)); + const request = new Request(url, { + method: options.method ?? "GET", + headers: { ...(options.headers as Record), Authorization: "Bearer token" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const response = await currentHandler(request); + const text = await response.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // Not JSON: n8n hands over the text. + } + if (!options.ignoreHttpStatusErrors && !response.ok) throw new Error(`HTTP ${response.status}`); + return options.returnFullResponse ? { body, headers: {}, statusCode: response.status, statusMessage: response.statusText } : body; +} + +/** The `helpers` a fake context carries. */ +export const fakeHelpers = { + httpRequestWithAuthentication: fakeHttpRequestWithAuthentication, + returnJsonArray: (data: unknown) => (Array.isArray(data) ? data : [data]).map((json) => ({ json })), +}; + /** Captures the last request seen by withMockedFetch and always answers with `body`. */ export function captureRequest(body: unknown = { response: {} }) { let request: Request | undefined; @@ -102,7 +132,7 @@ export function assertApiFailure(error: unknown, expected: { message: string; ht assert.equal(nodeError.message, expected.message); assert.equal(nodeError.httpCode, expected.httpCode); assert.equal(nodeError.context?.itemIndex, itemIndex); - assert.ok(!JSON.stringify(nodeError).includes("token="), "the error must not carry the request URL"); + assert.ok(!JSON.stringify(nodeError).includes("Bearer"), "the error must not carry the credential"); return true; } @@ -114,9 +144,8 @@ export function fakeExecuteContext(itemsParams: Array>, getInputData: () => itemsParams.map(() => ({ json: {} })), getNodeParameter: (name: string, itemIndex: number, fallback?: unknown) => name in itemsParams[itemIndex] ? itemsParams[itemIndex][name] : fallback, - getCredentials: async () => ({ accessToken: "token" }), continueOnFail: () => continueOnFail, - prepareOutputData: (data: unknown) => [data], + helpers: fakeHelpers, }; return context as never; } diff --git a/packages/n8n-nodes-teslemetry/test/triggerFilters.test.ts b/packages/n8n-nodes-teslemetry/test/triggerFilters.test.ts deleted file mode 100644 index 046e3f9..0000000 --- a/packages/n8n-nodes-teslemetry/test/triggerFilters.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { TeslemetryTrigger } from "../src/nodes/TeslemetryTrigger.node.js"; -import { fakeNode, withMockedFetch as withFetch } from "./testHelpers.js"; - -// trigger() checks the token against /api/test before it opens the stream. -const withMockedFetch: typeof withFetch = (handler, run) => - withFetch( - (request) => - new URL(request.url).pathname === "/api/test" - ? new Response(JSON.stringify({ response: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - : handler(request), - run, - ); - -function sseResponse(events: Array>) { - const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); -} - -function fakeTriggerContext(params: Record) { - const emitted: unknown[] = []; - const context = { - getCredentials: async () => ({ accessToken: "token" }), - getNodeParameter: (name: string, fallback?: unknown) => - name in params ? params[name] : fallback, - emit: (data: unknown) => emitted.push(data), - emitError: () => {}, - getNode: () => fakeNode, - getMode: () => "trigger", - logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, - helpers: { returnJsonArray: (data: unknown) => data }, - }; - return { context: context as never, emitted }; -} - -async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -const VIN_A = "5YJSA1E14FF000001"; -const VIN_B = "5YJSA1E14FF000002"; - -test("TeslemetryTrigger vehicle events filter by VIN when one is configured", async () => { - const { context, emitted } = fakeTriggerContext({ - resource: "vehicle", - event: "data", - vin: VIN_A, - }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch( - () => - sseResponse([ - { vin: VIN_B, data: { Soc: 50 }, createdAt: "2026-08-14T00:00:00.000000000Z" }, - { vin: VIN_A, data: { Soc: 60 }, createdAt: "2026-08-14T00:00:00.000000000Z" }, - ]), - () => node.trigger.call(context), - ); - - await waitFor(() => emitted.length > 0); - assert.equal(emitted.length, 1); - assert.equal((emitted[0] as [{ vin: string }])[0].vin, VIN_A); - - await result.closeFunction!(); -}); - -test("TeslemetryTrigger vehicle events pass through all VINs when none is configured", async () => { - const { context, emitted } = fakeTriggerContext({ - resource: "vehicle", - event: "data", - vin: "", - }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch( - () => - sseResponse([ - { vin: VIN_A, data: { Soc: 60 }, createdAt: "2026-08-14T00:00:00.000000000Z" }, - { vin: VIN_B, data: { Soc: 50 }, createdAt: "2026-08-14T00:00:00.000000000Z" }, - ]), - () => node.trigger.call(context), - ); - - await waitFor(() => emitted.length >= 2); - assert.equal(emitted.length, 2); - - await result.closeFunction!(); -}); - -test("TeslemetryTrigger energy events filter by site_id for live_status", async () => { - const { context, emitted } = fakeTriggerContext({ - resource: "energySite", - event: "live_status", - siteId: "123", - }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch( - () => - sseResponse([ - { site_id: 999, live_status: { soc: 1 } }, - { site_id: 123, live_status: { soc: 2 } }, - ]), - () => node.trigger.call(context), - ); - - await waitFor(() => emitted.length > 0); - assert.equal(emitted.length, 1); - assert.equal((emitted[0] as [{ site_id: number }])[0].site_id, 123); - - await result.closeFunction!(); -}); - -test("TeslemetryTrigger energy events filter by id (not site_id) for energy_totals", async () => { - const { context, emitted } = fakeTriggerContext({ - resource: "energySite", - event: "energy_totals", - siteId: "123", - }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch( - () => - sseResponse([ - { id: 999, totals: { total_home_usage: 1 } }, - { id: 123, totals: { total_home_usage: 2 } }, - ]), - () => node.trigger.call(context), - ); - - await waitFor(() => emitted.length > 0); - assert.equal(emitted.length, 1); - assert.equal((emitted[0] as [{ id: number }])[0].id, 123); - - await result.closeFunction!(); -}); - -test("TeslemetryTrigger energy events pass through all sites when none is configured", async () => { - const { context, emitted } = fakeTriggerContext({ - resource: "energySite", - event: "site_info", - siteId: "", - }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch( - () => - sseResponse([ - { site_id: 1, site_info: {} }, - { site_id: 2, site_info: {} }, - ]), - () => node.trigger.call(context), - ); - - await waitFor(() => emitted.length >= 2); - assert.equal(emitted.length, 2); - - await result.closeFunction!(); -}); - -test("TeslemetryTrigger signal events require a VIN and a field", async () => { - const node = new TeslemetryTrigger(); - - const { context: noVin } = fakeTriggerContext({ - resource: "vehicle", - event: "signal", - vin: "", - field: "Soc", - }); - await assert.rejects(() => node.trigger.call(noVin), /VIN is required/); - - const { context: noField } = fakeTriggerContext({ - resource: "vehicle", - event: "signal", - vin: VIN_A, - field: "", - }); - await assert.rejects(() => node.trigger.call(noField), /Field is required/); -}); diff --git a/packages/n8n-nodes-teslemetry/test/vehicleExecute.test.ts b/packages/n8n-nodes-teslemetry/test/vehicleExecute.test.ts index c75a203..11c4b0d 100644 --- a/packages/n8n-nodes-teslemetry/test/vehicleExecute.test.ts +++ b/packages/n8n-nodes-teslemetry/test/vehicleExecute.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { TeslemetryVehicle } from "../src/nodes/TeslemetryVehicle.node.js"; +import { TeslemetryVehicle } from "../nodes/TeslemetryVehicle/TeslemetryVehicle.node.js"; import { API_FAILURES, assertApiFailure, diff --git a/packages/n8n-nodes-teslemetry/tsconfig.json b/packages/n8n-nodes-teslemetry/tsconfig.json index da0400b..a6dedb4 100644 --- a/packages/n8n-nodes-teslemetry/tsconfig.json +++ b/packages/n8n-nodes-teslemetry/tsconfig.json @@ -1,11 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": true, - "paths": { - "@teslemetry/api": ["../api/dist"] - } + "noEmit": true }, - "include": ["src/**/*.ts"], + "include": ["nodes/**/*.ts", "credentials/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/n8n-nodes-teslemetry/tsdown.config.ts b/packages/n8n-nodes-teslemetry/tsdown.config.ts index 9b45013..684e6d8 100644 --- a/packages/n8n-nodes-teslemetry/tsdown.config.ts +++ b/packages/n8n-nodes-teslemetry/tsdown.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "tsdown"; export default defineConfig({ // n8n loads one class per file listed in package.json's `n8n` block and takes // the class name from the file name, so each node and credential is its own - // entry: dist/nodes/.node.js, dist/credentials/.credentials.js. - entry: ["src/nodes/*.node.ts", "src/credentials/*.credentials.ts"], + // entry: dist/nodes//.node.js, dist/credentials/.credentials.js. + entry: ["nodes/*/*.node.ts", "credentials/*.credentials.ts"], format: "cjs", platform: "node", // n8n derives a node's codex path by appending "on" to the ".js" file name. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9422c45..0b09320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,9 +71,6 @@ importers: '@n8n/node-cli': specifier: ^0.51.5 version: 0.51.5(43e2d15aac755b04e49b4abf94855da0) - '@teslemetry/api': - specifier: workspace:* - version: link:../api eslint: specifier: 9.29.0 version: 9.29.0(jiti@2.6.1) From 9b78cf420746902e5ab28f7b1e69009c27c38e2c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 22 Oct 2026 08:40:05 +1000 Subject: [PATCH 2/4] feat(n8n): replace the SSE trigger with a polling trigger n8n Cloud forbids the long-lived reconnecting stream the SDK's SSE client needs (timers, events, global fetch), and emulating it with hidden timers would evade n8n's check. The trigger now uses n8n's poll() helper on the Poll Times schedule and emits only values that changed since the previous check. Vehicle events read GET /api/1/vehicles/{vin}/telemetry, the cached telemetry snapshot: it never wakes the car and costs no credits. Energy events read live_status or site_info, which Teslemetry serves from its own cache. A null tariff_content_v2 is reported as a tariff removal. Not carried over: the stream-only Credits, Config and Energy Totals events, and watching all vehicles or all sites at once. --- .../TeslemetryTrigger.node.ts | 301 ++++++++++++++++ .../nodes/TeslemetryTrigger/teslemetry.svg | 3 + .../n8n-nodes-teslemetry/test/trigger.test.ts | 335 ++++++------------ 3 files changed, 419 insertions(+), 220 deletions(-) create mode 100644 packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/TeslemetryTrigger.node.ts create mode 100644 packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/teslemetry.svg diff --git a/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/TeslemetryTrigger.node.ts b/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/TeslemetryTrigger.node.ts new file mode 100644 index 0000000..a672405 --- /dev/null +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/TeslemetryTrigger.node.ts @@ -0,0 +1,301 @@ +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IPollFunctions, + NodeConnectionTypes, + NodeOperationError, +} from 'n8n-workflow'; +import { loadOptions, siteOptions, teslemetryRequest, toNodeError, vehicleOptions } from '../shared'; + +// Snapshot topics that map a name (a telemetry field, a network interface) to +// a `{ value, createdAt }` leaf. Every other topic holds one value. +const MAP_TOPICS = ['data', 'connectivity']; +const VEHICLE_TOPICS = ['data', 'state', 'connectivity', 'alerts', 'errors', 'vehicle_data']; + +/** One value the trigger watches, under the key its last-seen copy is stored by. */ +type Reading = { key: string; topic: string; name?: string; value: unknown }; + +/** Splits a cached telemetry snapshot into the readings for the chosen event. */ +function vehicleReadings(snapshot: IDataObject, event: string, field: string): Reading[] { + if (event === 'signal') { + const leaf = (snapshot.data as IDataObject | undefined)?.[field]; + return leaf === undefined ? [] : [{ key: `signal.${field}`, topic: 'signal', name: field, value: leaf }]; + } + const readings: Reading[] = []; + for (const topic of event === 'all' ? VEHICLE_TOPICS : [event]) { + const value = snapshot[topic]; + if (value === undefined) continue; + if (MAP_TOPICS.includes(topic) && value && typeof value === 'object') { + for (const [name, leaf] of Object.entries(value)) { + readings.push({ key: `${topic}.${name}`, topic, name, value: leaf }); + } + } else { + readings.push({ key: topic, topic, value }); + } + } + return readings; +} + +/** Turns the changed readings into one output item per topic. */ +function vehicleItems(vin: string, changed: Reading[]): IDataObject[] { + const items = new Map(); + for (const reading of changed) { + if (reading.topic === 'signal') { + const leaf = (reading.value ?? {}) as IDataObject; + items.set(reading.key, { vin, topic: 'signal', field: reading.name, value: leaf.value, createdAt: leaf.createdAt }); + continue; + } + const item = items.get(reading.topic) ?? { vin, topic: reading.topic }; + if (reading.name === undefined) { + item[reading.topic] = reading.value as IDataObject; + } else { + item[reading.topic] = { ...(item[reading.topic] as IDataObject | undefined), [reading.name]: reading.value }; + } + items.set(reading.topic, item); + } + return [...items.values()]; +} + +export class TeslemetryTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Teslemetry Trigger', + name: 'teslemetryTrigger', + icon: 'file:teslemetry.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["event"]}}', + description: + "Starts the workflow when a vehicle's cached telemetry or an energy site's status changes, checked on a schedule", + defaults: { + name: 'Teslemetry Trigger', + }, + polling: true, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'teslemetryApi', + required: true, + }, + ], + properties: [ + { + displayName: + 'Checks Teslemetry on the Poll Times schedule and starts the workflow for each value that changed since the last check. Vehicle checks read the cached telemetry, which never wakes the car and costs no credits.', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Vehicle', value: 'vehicle' }, + { name: 'Energy Site', value: 'energySite' }, + ], + default: 'vehicle', + }, + { + displayName: 'VIN Name or ID', + name: 'vin', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getVins', + }, + default: '', + required: true, + description: + 'Vehicle to watch. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + resource: ['vehicle'], + }, + }, + }, + { + displayName: 'Site Name or ID', + name: 'siteId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + required: true, + description: + 'Energy site to watch. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + resource: ['energySite'], + }, + }, + }, + { + displayName: 'Event Type', + name: 'event', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Alerts', value: 'alerts' }, + { name: 'Any Change', value: 'all', description: 'Any change to the topics below' }, + { name: 'Connectivity', value: 'connectivity', description: 'A network interface connected or disconnected' }, + { name: 'Data', value: 'data', description: 'Any telemetry field changed' }, + { name: 'Errors', value: 'errors' }, + { name: 'Signal', value: 'signal', description: 'One telemetry field changed' }, + { name: 'State', value: 'state', description: 'The vehicle went online, asleep or offline' }, + { name: 'Vehicle Data', value: 'vehicle_data' }, + ], + default: 'all', + required: true, + displayOptions: { + show: { + resource: ['vehicle'], + }, + }, + }, + { + displayName: 'Event Type', + name: 'event', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Live Status', + value: 'live_status', + description: 'Power flows, battery level and grid status (refreshed about every 30 seconds)', + }, + { + name: 'Site Info', + value: 'site_info', + description: 'Settings such as backup reserve and operation mode (refreshed about every 5 minutes)', + }, + { name: 'Tariff Content', value: 'tariff_content_v2', description: 'The site tariff changed or was removed' }, + ], + default: 'live_status', + required: true, + displayOptions: { + show: { + resource: ['energySite'], + }, + }, + }, + { + displayName: 'Signal Field Name or ID', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFields', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: ['vehicle'], + event: ['signal'], + }, + }, + description: + 'The telemetry field to watch. Choose from the list, or specify an ID using an expression.', + }, + ], + }; + + methods = { + loadOptions: { + async getVins(this: ILoadOptionsFunctions): Promise { + return loadOptions(this, () => vehicleOptions(this)); + }, + async getSites(this: ILoadOptionsFunctions): Promise { + return loadOptions(this, () => siteOptions(this)); + }, + async getFields(this: ILoadOptionsFunctions): Promise { + return loadOptions(this, async () => { + const fields = await teslemetryRequest(this, 'GET', '/fields.json'); + return Object.keys(fields).map((field) => ({ name: field, value: field })); + }); + }, + }, + }; + + async poll(this: IPollFunctions): Promise { + const resource = this.getNodeParameter('resource') as string; + const event = this.getNodeParameter('event') as string; + + let readings: Reading[]; + let toItems: (changed: Reading[]) => IDataObject[]; + let subject: string; + try { + if (resource === 'energySite') { + const siteId = this.getNodeParameter('siteId') as string; + if (!siteId) throw new NodeOperationError(this.getNode(), 'Choose the energy site to watch'); + subject = `site:${siteId}:${event}`; + const path = event === 'live_status' ? 'live_status' : 'site_info'; + const { response } = await teslemetryRequest<{ response?: IDataObject }>( + this, + 'GET', + `/api/1/energy_sites/${siteId}/${path}`, + ); + // tariff_content_v2 is null when the site's tariff was removed, which is a + // change worth reporting; absent means the site has never had one. + const value = event === 'tariff_content_v2' ? response?.tariff_content_v2 : response; + readings = value === undefined ? [] : [{ key: event, topic: event, value }]; + toItems = (changed) => + changed.map((reading) => + event === 'tariff_content_v2' + ? { site_id: siteId, tariff_content_v2: reading.value as IDataObject } + : { site_id: siteId, ...(reading.value as IDataObject) }, + ); + } else { + const vin = this.getNodeParameter('vin') as string; + const field = event === 'signal' ? (this.getNodeParameter('field', '') as string) : ''; + if (!vin) throw new NodeOperationError(this.getNode(), 'Choose the vehicle to watch'); + if (event === 'signal' && !field) { + throw new NodeOperationError(this.getNode(), 'Choose the field to watch for Signal events'); + } + subject = `vehicle:${vin}:${event}:${field}`; + const qs: IDataObject = {}; + if (event === 'signal') qs.fields = field; + else if (event !== 'all') qs.topics = event; + const { response } = await teslemetryRequest<{ response?: IDataObject }>( + this, + 'GET', + `/api/1/vehicles/${vin}/telemetry`, + { qs }, + ); + readings = vehicleReadings(response ?? {}, event, field); + toItems = (changed) => vehicleItems(vin, changed); + } + } catch (error) { + throw toNodeError(this.getNode(), error); + } + + // A manual test run shows the current values; it does not move the baseline. + if (this.getMode() === 'manual') { + const items = toItems(readings); + return items.length ? [this.helpers.returnJsonArray(items)] : null; + } + + // The first check after activation, or after the watched subject changed, + // only records a baseline: a value is new only against an earlier one. + const staticData = this.getWorkflowStaticData('node') as { subject?: string; seen?: Record }; + const firstCheck = staticData.subject !== subject || !staticData.seen; + const seen: Record = firstCheck ? {} : { ...staticData.seen }; + const changed: Reading[] = []; + for (const reading of readings) { + const serialized = JSON.stringify(reading.value); + if (seen[reading.key] !== serialized) { + seen[reading.key] = serialized; + changed.push(reading); + } + } + staticData.subject = subject; + staticData.seen = seen; + if (firstCheck || changed.length === 0) return null; + return [this.helpers.returnJsonArray(toItems(changed))]; + } +} diff --git a/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/teslemetry.svg b/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/teslemetry.svg new file mode 100644 index 0000000..f89764c --- /dev/null +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryTrigger/teslemetry.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/n8n-nodes-teslemetry/test/trigger.test.ts b/packages/n8n-nodes-teslemetry/test/trigger.test.ts index 0a4b464..e4de5ad 100644 --- a/packages/n8n-nodes-teslemetry/test/trigger.test.ts +++ b/packages/n8n-nodes-teslemetry/test/trigger.test.ts @@ -1,268 +1,163 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { TeslemetryTrigger } from "../src/nodes/TeslemetryTrigger.node.js"; -import { apiError, fakeLogger, fakeNode, withMockedFetch } from "./testHelpers.js"; +import { TeslemetryTrigger } from "../nodes/TeslemetryTrigger/TeslemetryTrigger.node.js"; +import { apiError, fakeHelpers, fakeNode, withMockedFetch } from "./testHelpers.js"; -const TOKEN = "secret-access-token"; +const VIN = "5YJSA1E14FF000000"; +const SITE_ID = 123; -async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -function fakeTriggerContext(params: Record, mode = "trigger") { - const emitted: unknown[] = []; - const emittedErrors: Error[] = []; - const { lines, logger } = fakeLogger(); +/** Minimal IPollFunctions stand-in; `staticData` persists across polls like n8n's node static data. */ +function fakePollContext(params: Record, mode = "trigger", staticData: Record = {}) { const context = { - getCredentials: async () => ({ accessToken: TOKEN }), - getNodeParameter: (name: string, fallback?: unknown) => - name in params ? params[name] : fallback, + getNodeParameter: (name: string, fallback?: unknown) => (name in params ? params[name] : fallback), getNode: () => fakeNode, getMode: () => mode, - emit: (data: unknown) => emitted.push(data), - emitError: (error: Error) => emittedErrors.push(error), - logger, - helpers: { - returnJsonArray: (data: unknown) => data, - }, + getWorkflowStaticData: () => staticData, + helpers: fakeHelpers, }; - return { context: context as never, emitted, emittedErrors, lines }; + return { context: context as never, staticData }; } -const ALL_VEHICLE_EVENTS = { resource: "vehicle", event: "all", vin: "" }; +function json(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} -/** Answers trigger()'s token check and the stream separately, counting stream attempts. */ -function teslemetryServer(answers: { test?: () => Response; stream: () => Response }) { - const seen = { test: 0, stream: 0 }; +/** Answers each request with the next body and records the requests. */ +function server(...bodies: unknown[]) { + const requests: URL[] = []; const handler = (request: Request) => { - if (new URL(request.url).pathname === "/api/test") { - seen.test += 1; - return ( - answers.test?.() ?? - new Response(JSON.stringify({ response: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); - } - seen.stream += 1; - return answers.stream(); + requests.push(new URL(request.url)); + return json(bodies[Math.min(requests.length, bodies.length) - 1]); }; - return { handler, seen }; + return { handler, requests }; } -const streamStatus = (status: number, statusText: string) => () => new Response(null, { status, statusText }); - -for (const c of [ - { - name: "a rejected token (401)", - response: () => apiError(401, "invalid_token", "Invalid authentication token"), - message: "Teslemetry rejected the access token: Invalid authentication token", - httpCode: "401", - }, - { - name: "a lapsed subscription (402)", - response: () => apiError(402, "subscription_required", "Active subscription required"), - message: "Teslemetry requires an active subscription: Active subscription required", - httpCode: "402", - }, - { - name: "the api's repeated-failure block (429)", - response: () => apiError(429, "rate_limited", "Rate limit exceeded, retry after 5 seconds"), - message: - "Teslemetry is limiting requests or stream connections for this account: Rate limit exceeded, retry after 5 seconds", - httpCode: "429", - }, -]) { - test(`TeslemetryTrigger fails activation on ${c.name} without opening the stream`, async () => { - const { context, emittedErrors } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler, seen } = teslemetryServer({ test: c.response, stream: streamStatus(200, "OK") }); - const node = new TeslemetryTrigger(); +const leaf = (value: unknown, createdAt = "2026-10-22T00:00:00Z") => ({ value, createdAt }); - await assert.rejects( - () => withMockedFetch(handler, () => node.trigger.call(context)), - (error: Error & { httpCode?: string }) => { - assert.equal(error.message, c.message); - assert.equal(error.httpCode, c.httpCode); - assert.ok(!JSON.stringify(error).includes(TOKEN), "the error must not carry the token"); - return true; - }, - ); - assert.equal(seen.stream, 0); - assert.equal(emittedErrors.length, 0); - }); -} - -test("TeslemetryTrigger still connects when the token check fails for a reason that may clear", async () => { - const { context, lines } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler, seen } = teslemetryServer({ - test: () => new Response("Service Unavailable", { status: 503 }), - stream: streamStatus(500, "Server Error"), - }); +/** Polls once per body against one persisted static data, returning each poll's items. */ +async function pollEach(params: Record, ...bodies: unknown[]) { + const { handler, requests } = server(...bodies); + const { context } = fakePollContext(params); const node = new TeslemetryTrigger(); + const results = []; + for (let i = 0; i < bodies.length; i++) { + const result = await withMockedFetch(handler, () => node.poll.call(context)); + results.push(result ? result[0].map((item: { json: unknown }) => item.json) : null); + } + return { results, requests }; +} - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => seen.stream > 0); - return response; +test("TeslemetryTrigger records a baseline on the first poll and then emits only changed fields", async () => { + const snapshot = (soc: number) => ({ + response: { vin: VIN, state: leaf("online"), data: { BatteryLevel: leaf(soc), Locked: leaf(true) } }, }); + const { results, requests } = await pollEach({ resource: "vehicle", vin: VIN, event: "data" }, snapshot(80), snapshot(80), snapshot(81)); - assert.ok(lines.includes("warn: Teslemetry check before connecting failed: Service Unavailable"), lines.join("\n")); - await result.closeFunction!(); + assert.equal(requests[0].pathname, `/api/1/vehicles/${VIN}/telemetry`); + assert.equal(requests[0].searchParams.get("topics"), "data"); + assert.deepEqual(results, [null, null, [{ vin: VIN, topic: "data", data: { BatteryLevel: leaf(81) } }]]); }); -test("TeslemetryTrigger surfaces a terminal auth failure via emitError", async () => { - const { context, emittedErrors } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler } = teslemetryServer({ stream: streamStatus(401, "Unauthorized") }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => emittedErrors.length > 0); - return response; - }); - - assert.equal(emittedErrors.length, 1); - assert.equal(emittedErrors[0].message, "Teslemetry rejected the access token (HTTP 401)"); +test("TeslemetryTrigger Any Change reads the full snapshot and emits one item per changed topic", async () => { + const { results, requests } = await pollEach( + { resource: "vehicle", vin: VIN, event: "all" }, + { response: { state: leaf("asleep"), data: { Locked: leaf(true) }, alerts: null } }, + { response: { state: leaf("online"), data: { Locked: leaf(false) }, alerts: null } }, + ); - // Idempotent cleanup: calling closeFunction twice must not throw. - await result.closeFunction!(); - await result.closeFunction!(); + assert.equal(requests[0].search, ""); + assert.deepEqual(results[1], [ + { vin: VIN, topic: "data", data: { Locked: leaf(false) } }, + { vin: VIN, topic: "state", state: leaf("online") }, + ]); }); -test("TeslemetryTrigger reports a subscription that lapses on the stream once, not per retry", async () => { - const { context, emittedErrors } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler, seen } = teslemetryServer({ stream: streamStatus(402, "Payment Required") }); - const node = new TeslemetryTrigger(); - - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - // The SDK retries after 2 s; wait for that second refusal. - await waitFor(() => seen.stream >= 2, 5000); - return response; - }); +test("TeslemetryTrigger Signal reads one field and emits its value", async () => { + const { results, requests } = await pollEach( + { resource: "vehicle", vin: VIN, event: "signal", field: "BatteryLevel" }, + { response: { data: { BatteryLevel: leaf(50, "t1") } } }, + { response: { data: { BatteryLevel: leaf(51, "t2") } } }, + ); - assert.equal(emittedErrors.length, 1); - assert.equal(emittedErrors[0].message, "Teslemetry requires an active subscription (HTTP 402)"); - await result.closeFunction!(); + assert.equal(requests[0].searchParams.get("fields"), "BatteryLevel"); + assert.deepEqual(results[1], [{ vin: VIN, topic: "signal", field: "BatteryLevel", value: 51, createdAt: "t2" }]); }); -test("TeslemetryTrigger logs the stream's connection cap (429) once and keeps an active workflow retrying", async () => { - const { context, emittedErrors, lines } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler, seen } = teslemetryServer({ stream: streamStatus(429, "Too Many Requests") }); +test("TeslemetryTrigger Signal requires a VIN and a field", async () => { const node = new TeslemetryTrigger(); - - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => seen.stream >= 2, 5000); - return response; - }); - - // emitError would make n8n reactivate the trigger straight back into the same 429. - assert.equal(emittedErrors.length, 0); - assert.deepEqual( - lines.filter((line) => line.includes("limiting")), - ["error: Teslemetry is limiting requests or stream connections for this account (HTTP 429). The stream keeps retrying."], + await assert.rejects( + () => node.poll.call(fakePollContext({ resource: "vehicle", vin: "", event: "signal", field: "Soc" }).context), + /Choose the vehicle to watch/, + ); + await assert.rejects( + () => node.poll.call(fakePollContext({ resource: "vehicle", vin: VIN, event: "signal" }).context), + /Choose the field to watch/, ); - await result.closeFunction!(); }); -test("TeslemetryTrigger fails a manual test run on the stream's connection cap (429) instead of waiting", async () => { - const { context, emittedErrors } = fakeTriggerContext(ALL_VEHICLE_EVENTS, "manual"); - const { handler } = teslemetryServer({ stream: streamStatus(429, "Too Many Requests") }); +test("TeslemetryTrigger starts a new baseline when the watched vehicle changes", async () => { + const { handler } = server({ response: { data: { Locked: leaf(true) } } }, { response: { data: { Locked: leaf(false) } } }); + const staticData = {}; const node = new TeslemetryTrigger(); - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => emittedErrors.length > 0); - return response; - }); - - assert.equal( - emittedErrors[0].message, - "Teslemetry is limiting requests or stream connections for this account (HTTP 429)", + await withMockedFetch(handler, () => + node.poll.call(fakePollContext({ resource: "vehicle", vin: VIN, event: "data" }, "trigger", staticData).context), + ); + const result = await withMockedFetch(handler, () => + node.poll.call(fakePollContext({ resource: "vehicle", vin: "OTHERVIN", event: "data" }, "trigger", staticData).context), ); - await result.closeFunction!(); + + assert.equal(result, null); }); -test("TeslemetryTrigger logs stream disconnects without surfacing them as trigger errors", async () => { - const { context, emittedErrors, lines } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler } = teslemetryServer({ stream: streamStatus(500, "Server Error") }); - const node = new TeslemetryTrigger(); +test("TeslemetryTrigger shows the current values on a manual run without moving the baseline", async () => { + const { handler } = server({ response: { state: leaf("online") } }); + const { context, staticData } = fakePollContext({ resource: "vehicle", vin: VIN, event: "state" }, "manual"); - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => lines.some((line) => line.includes("disconnected"))); - return response; - }); + const result = await withMockedFetch(handler, () => new TeslemetryTrigger().poll.call(context)); - assert.equal(emittedErrors.length, 0); - await result.closeFunction!(); + assert.deepEqual(result?.[0].map((item: { json: unknown }) => item.json), [{ vin: VIN, topic: "state", state: leaf("online") }]); + assert.deepEqual(staticData, {}); }); -test("TeslemetryTrigger sends the SDK's log lines to n8n's logger, never the console, and never with the token", async () => { - const { context, lines } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - const { handler } = teslemetryServer({ stream: streamStatus(500, "Server Error") }); - const node = new TeslemetryTrigger(); - - const consoleCalls: unknown[][] = []; - const original = { debug: console.debug, info: console.info, warn: console.warn, error: console.error }; - for (const level of ["debug", "info", "warn", "error"] as const) { - console[level] = (...args: unknown[]) => void consoleCalls.push(args); - } - try { - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => lines.some((line) => line.includes("SSE error"))); - return response; - }); - await result.closeFunction!(); - } finally { - Object.assign(console, original); - } +test("TeslemetryTrigger energy Live Status emits the site's status when it changes", async () => { + const { results, requests } = await pollEach( + { resource: "energySite", siteId: SITE_ID, event: "live_status" }, + { response: { solar_power: 100, timestamp: "t1" } }, + { response: { solar_power: 200, timestamp: "t2" } }, + ); - assert.deepEqual(consoleCalls, []); - assert.ok(lines.includes("debug: Response from /api/test: 200"), lines.join("\n")); - // A rejected attempt never announces a connection: the SDK logs it only on - // the stream's first chunk. - assert.ok(!lines.includes("debug: Connected to stream"), lines.join("\n")); - assert.ok(lines.includes("error: SSE error: Error: SSE failed: 500 Server Error"), lines.join("\n")); - assert.ok(!lines.some((line) => line.includes(TOKEN) || line.includes("token=")), lines.join("\n")); + assert.equal(requests[0].pathname, `/api/1/energy_sites/${SITE_ID}/live_status`); + assert.deepEqual(results[1], [{ site_id: SITE_ID, solar_power: 200, timestamp: "t2" }]); }); -test("TeslemetryTrigger.closeFunction tears down listeners and stops the stream", async () => { - const { context, emitted } = fakeTriggerContext(ALL_VEHICLE_EVENTS); - // One event, then the stream stays open as the real one does. - const { handler } = teslemetryServer({ - stream: () => - new Response( - new ReadableStream({ - start(controller) { - const event = { vin: "5YJSA1E14FF000000", state: "online" }; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); - }, - }), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - }); - const node = new TeslemetryTrigger(); +test("TeslemetryTrigger energy Tariff Content reports a removed tariff (null) as a change", async () => { + const { results, requests } = await pollEach( + { resource: "energySite", siteId: SITE_ID, event: "tariff_content_v2" }, + { response: { tariff_content_v2: { name: "TOU" } } }, + { response: { tariff_content_v2: null } }, + { response: {} }, + ); - const result = await withMockedFetch(handler, async () => { - const response = await node.trigger.call(context); - await waitFor(() => emitted.length > 0); - return response; - }); + assert.equal(requests[0].pathname, `/api/1/energy_sites/${SITE_ID}/site_info`); + assert.deepEqual(results.slice(1), [[{ site_id: SITE_ID, tariff_content_v2: null }], null]); +}); - await result.closeFunction!(); - const countAfterClose = emitted.length; +test("TeslemetryTrigger fails the poll with the api's text and no credential", async () => { + const { context } = fakePollContext({ resource: "vehicle", vin: VIN, event: "all" }); - // A second close is a no-op, not a re-teardown attempt. - await result.closeFunction!(); - assert.equal(emitted.length, countAfterClose); + await assert.rejects( + () => + withMockedFetch( + () => apiError(402, "subscription_required", "Active subscription required"), + () => new TeslemetryTrigger().poll.call(context), + ), + (error: Error & { httpCode?: string }) => { + assert.equal(error.name, "NodeApiError"); + assert.equal(error.message, "Active subscription required"); + assert.equal(error.httpCode, "402"); + assert.ok(!JSON.stringify(error).includes("Bearer")); + return true; + }, + ); }); From c6c207b3579345a008c51085d712efa49f86e920 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 22 Oct 2026 08:40:05 +1000 Subject: [PATCH 3/4] docs(n8n): document the polling trigger and the SDK-free nodes README describes the resources and the polling trigger (interval, cached reads, credit cost, dropped events). The add-command skill and AGENTS.md no longer say the n8n package uses @teslemetry/api. Changeset: minor. --- .changeset/n8n-cloud-safe-nodes.md | 8 ++++ .../add-command-to-integrations/SKILL.md | 6 ++- AGENTS.md | 2 +- packages/n8n-nodes-teslemetry/README.md | 46 ++++++++++--------- 4 files changed, 37 insertions(+), 25 deletions(-) create mode 100644 .changeset/n8n-cloud-safe-nodes.md diff --git a/.changeset/n8n-cloud-safe-nodes.md b/.changeset/n8n-cloud-safe-nodes.md new file mode 100644 index 0000000..827a2c0 --- /dev/null +++ b/.changeset/n8n-cloud-safe-nodes.md @@ -0,0 +1,8 @@ +--- +"@teslemetry/n8n-nodes-teslemetry": minor +--- + +Rebuild the nodes on n8n's own HTTP helper so the package passes n8n's verification scanner. The package no longer bundles `@teslemetry/api`. + +- **Teslemetry Vehicle** and **Teslemetry Energy** now group their operations by resource. Operation values are unchanged, so saved workflows keep running; the editor may ask you to pick the resource again for an operation outside the default resource. +- **Teslemetry Trigger** is now a polling trigger. Vehicle events read the cached telemetry (no credits, never wakes the car); energy events read `live_status`/`site_info`. A VIN or site is now required, and the stream-only events Credits, Config and Energy Totals are gone. diff --git a/.claude/skills/add-command-to-integrations/SKILL.md b/.claude/skills/add-command-to-integrations/SKILL.md index 22c827f..ecb8cce 100644 --- a/.claude/skills/add-command-to-integrations/SKILL.md +++ b/.claude/skills/add-command-to-integrations/SKILL.md @@ -18,9 +18,11 @@ Only **commands** (writes) need this. A new telemetry field needs no code: the N - Add a case to `test/teslemetry-vehicle-command.test.ts` (or `teslemetry-energy-command.test.ts`). 3. **n8n** (`packages/n8n-nodes-teslemetry`): - - In `src/nodes/TeslemetryVehicle.node.ts` (energy: `TeslemetryEnergy.node.ts`), add an `options` entry (`name`, `value`, `action`) to the operation list. + - The package does not use `@teslemetry/api` (n8n's verification forbids it); it calls the endpoint itself through `teslemetryRequest()` in `nodes/shared.ts`. Copy the path and body from the SDK method. + - In `nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts` (energy: `nodes/TeslemetryEnergy/TeslemetryEnergy.node.ts`), add an `options` entry (`name`, `value`, `action`) to the operation list of the matching resource, keeping it alphabetical. - Add one property per argument, shown only for that operation with `displayOptions: { show: { operation: [''] } }`. - - Add a `case '':` to the `execute` switch. Read arguments with `this.getNodeParameter('', itemIndex)`. + - Add an `OPERATIONS` entry that builds the request (`command('', (param) => ({ ... }))` for `command/`). Read arguments with `param('')`. + - Run `pnpm --filter @teslemetry/n8n-nodes-teslemetry validate` (n8n's lint). - Add the operation to `README.md`. - Add a row (operation, params, expected path and body) to the table in `test/vehicleExecute.test.ts` (or `energyExecute.test.ts`). diff --git a/AGENTS.md b/AGENTS.md index 7529f13..db46d7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Teslemetry TypeScript Monorepo -pnpm workspaces + changesets. `packages/api` is the `@teslemetry/api` SDK; the other packages depend on it via `workspace:*`. The ioBroker adapter lives in its own repo, [Teslemetry/ioBroker.teslemetry](https://github.com/Teslemetry/ioBroker.teslemetry). +pnpm workspaces + changesets. `packages/api` is the `@teslemetry/api` SDK; the other packages except `n8n-nodes-teslemetry` depend on it via `workspace:*`. The ioBroker adapter lives in its own repo, [Teslemetry/ioBroker.teslemetry](https://github.com/Teslemetry/ioBroker.teslemetry). ## Development diff --git a/packages/n8n-nodes-teslemetry/README.md b/packages/n8n-nodes-teslemetry/README.md index 58b4029..208e7af 100644 --- a/packages/n8n-nodes-teslemetry/README.md +++ b/packages/n8n-nodes-teslemetry/README.md @@ -9,8 +9,7 @@ n8n community nodes for controlling Tesla vehicles and energy sites via the [Tes - 🚗 **Vehicle Control**: Lock/unlock, climate, charging, navigation, and more - ⚡ **Energy Management**: Monitor and control Powerwall and Solar systems -- 📡 **Real-Time Events**: Trigger workflows based on vehicle state changes -- 🔄 **Full API Coverage**: Access all Teslemetry API features +- 📡 **Change Events**: Trigger workflows when vehicle telemetry or energy site status changes ## Installation @@ -51,7 +50,7 @@ Then restart your n8n instance. ## Nodes ### Teslemetry Vehicle -Perform operations on your Tesla vehicles. +Perform operations on your Tesla vehicles. Operations are grouped by resource: **Vehicle**, **Doors and Closure**, **Climate** and **Charging**. **Operations:** - **Get Vehicle Data**: Retrieves comprehensive data. @@ -68,7 +67,7 @@ Perform operations on your Tesla vehicles. - **Navigation Request**: Send a destination to the vehicle navigation. ### Teslemetry Energy -Interact with Tesla Energy sites (Solar/Powerwall). +Interact with Tesla Energy sites (Solar/Powerwall). Operations are grouped by resource: **Status** and **Setting**. **Operations:** - **Get Live Status**: Live power usage details. @@ -81,19 +80,22 @@ Interact with Tesla Energy sites (Solar/Powerwall). - **Off-Grid Reserve**: Set vehicle charging reserve for off-grid operation. ### Teslemetry Trigger -Trigger workflows based on real-time vehicle events via Server-Sent Events (SSE). - -**Event Types:** -- **All Events**: Stream all events -- **Data**: Real-time telemetry data updates -- **State**: State changes (online/asleep/charging) -- **Vehicle Data**: Full vehicle data snapshots -- **Errors**: Vehicle error events -- **Alerts**: Vehicle alerts and notifications -- **Connectivity**: Connection status changes -- **Credits**: API credit usage updates -- **Config**: Configuration changes -- **Signal**: Monitor specific fields (e.g., `speed`, `odometer`, `battery_level`) +A polling trigger: it checks Teslemetry on the node's **Poll Times** schedule (every minute by default) and starts the workflow once for each value that changed since the previous check. The first check after you activate the workflow only records the current values. A manual test run returns the current values. + +**Vehicle events** read the vehicle's cached telemetry (`GET /api/1/vehicles/{vin}/telemetry`). This read never wakes the vehicle, never calls Tesla, and costs no credits, so a short poll interval is free. The vehicle must stream the fields you watch (add them in the Teslemetry console). +- **Any Change**: Any change to the topics below +- **Data**: Any telemetry field changed (the item lists only the changed fields) +- **Signal**: One telemetry field changed (choose the field) +- **State**: The vehicle went online, asleep or offline +- **Connectivity**: A network interface connected or disconnected +- **Alerts**, **Errors**, **Vehicle Data**: The topic's cached value changed + +**Energy site events** read the site's `live_status` or `site_info`. Teslemetry serves both from its own cache, which it refreshes about every 30 seconds (live status) and every 5 minutes (site info), so polling faster than that returns the same values. Credits are charged only when Teslemetry fetches fresh data from Tesla, not for cache reads. +- **Live Status**: Power flows, battery level and grid status +- **Site Info**: Settings such as backup reserve and operation mode +- **Tariff Content**: The site tariff changed, or was removed (`tariff_content_v2: null`) + +Earlier versions streamed events over Server-Sent Events (SSE). n8n Cloud does not allow the long-lived reconnecting connection a stream needs, so the trigger now polls. The stream-only events **Credits**, **Config** and **Energy Totals**, and the "all vehicles"/"all sites" choice, are no longer available. ## Usage Examples @@ -114,16 +116,16 @@ Trigger workflows based on real-time vehicle events via Server-Sent Events (SSE) ### Example 3: Monitor Charging Status 1. Add a **Teslemetry Trigger** node -2. Set event type to **Data** -3. Filter for charging-related updates -4. Send notifications when charging completes +2. Set event type to **Signal** and the field to `DetailedChargeState` +3. Add an **IF** node that checks `{{ $json.value }}` is `DetailedChargeStateComplete` +4. Send a notification when charging completes ### Example 4: Alert on Low Battery 1. Add a **Teslemetry Trigger** node 2. Set event type to **Signal** -3. Select signal: `battery_level` -4. Add an **IF** node to check if battery < 20% +3. Select the field `BatteryLevel` +4. Add an **IF** node to check if `{{ $json.value }}` < 20 5. Send notification via email/SMS ## Resources From c95ba98c7a5544e5c90acdc90a1c3c3bf55ded41 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 22 Oct 2026 09:01:07 +1000 Subject: [PATCH 4/4] fix(n8n): label the charging time field without the HH:Mm typo n8n's title-case lint turned 'HH:mm' into 'HH:Mm', which reads as month. The format now sits in the placeholder and the description. --- .../nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts b/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts index a4626b4..4f568dd 100644 --- a/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts +++ b/packages/n8n-nodes-teslemetry/nodes/TeslemetryVehicle/TeslemetryVehicle.node.ts @@ -786,11 +786,12 @@ export class TeslemetryVehicle implements INodeType { }, }, { - displayName: 'Charging Time (HH:Mm)', + displayName: 'Charging Time', name: 'schedule_time', type: 'string', default: '00:00', - description: 'Time of day charging should start, in the vehicle\'s local time', + placeholder: 'HH:mm', + description: 'Time of day charging should start (24-hour HH:mm), in the vehicle\'s local time', displayOptions: { show: { operation: ['setScheduledCharging'],