diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fa97df8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Bash(node -e:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/battery.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/common.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/codec.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/wire.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/connection.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/discovery.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/auth.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/odometer.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/health.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/odometer.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/health.py)", + "Bash(node --check drivers/vehicle/device.js)", + "Bash(node --check drivers/vehicle/driver.js)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/charging.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/target_soc.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/amp_limit.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/charging.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/chronos.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/charge_now.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/vehicle.py)", + "Bash(node --check clone_modules/polestar-c3/client.js)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/exterior.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/exterior.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/climate.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/climate.py)", + "Bash(node --check clone_modules/polestar-c3/messages.js)", + "Bash(node --check clone_modules/polestar-c3/compat.js)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/invocation.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/locks.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/honkflash.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/climatization.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/invocation.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/location.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/location.py)", + "Bash(node --check test-c3-poc.js)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/services/ota.py)", + "Bash(curl -sSL https://raw.githubusercontent.com/kildahldev/unofficial-polestar-api/main/src/polestar_api/models/ota.py)", + "WebFetch(domain:apps.developer.homey.app)" + ], + "deny": [], + "ask": [], + "additionalDirectories": [ + "c:\\source\\repos\\Polestar\\clone_modules" + ] + } +} diff --git a/.claude/skills/homey-widget-api.md b/.claude/skills/homey-widget-api.md new file mode 100644 index 0000000..42c6a8d --- /dev/null +++ b/.claude/skills/homey-widget-api.md @@ -0,0 +1,367 @@ +# Homey Widget/Dashboard API Guide + +This skill documents how to work with the built-in Homey API from dashboard widgets in this project. + +## Overview + +The Homey framework provides an internal message-based API system for communication between HTML widgets and the Homey app. This is NOT a traditional REST server - it's Homey's built-in widget API framework. + +## Architecture + +``` +Widget HTML (index.html) + │ + ├─► Homey.api('GET', '/endpoint') [Request] + │ + ▼ +Widget API Handler (api.js) + │ + ├─► Access device capabilities + │ + ▼ +Response returned to Widget + │ + ▼ +Widget renders the data +``` + +## File Structure + +``` +widgets// +├── public/ +│ └── index.html # Widget HTML with JavaScript +├── api.js # Backend API handler functions +└── widget.compose.json # Widget configuration & API definition +``` + +## 1. Defining API Endpoints + +API endpoints are defined in `widget.compose.json`: + +```json +{ + "name": { "en": "Widget Name" }, + "height": 188, + "transparent": true, + "settings": [ + { + "id": "device", + "type": "autocomplete", + "title": { "en": "Vehicle" } + } + ], + "api": { + "getVehicles": { + "method": "GET", + "path": "/" + }, + "getVehicleStatus": { + "method": "GET", + "path": "/status" + } + } +} +``` + +The `api` object maps function names to HTTP-like endpoints: +- Keys (e.g., `getVehicleStatus`) must match exported function names in `api.js` +- `method`: HTTP method (`GET`, `POST`, etc.) +- `path`: Endpoint path (relative to widget) + +## 2. Implementing API Handlers + +Create `api.js` to implement the endpoint handlers: + +```javascript +'use strict'; + +module.exports = { + // Handler for GET /status + async getVehicleStatus({ homey, query }) { + const { registration } = query; // Access query parameters + + // Get driver and find device + const driver = await homey.drivers.getDriver('vehicle'); + const vehicle = driver.getDevices().find( + device => device.getData().registration === registration + ); + + if (!vehicle) { + throw new Error('Vehicle Not Found'); + } + + // Return capability values + return { + battery: vehicle.getCapabilityValue('measure_polestarBattery'), + connected: vehicle.getCapabilityValue('measure_vehicleConnected'), + charging: vehicle.getCapabilityValue('measure_vehicleChargeState'), + current: vehicle.getCapabilityValue('measure_current'), + power: vehicle.getCapabilityValue('measure_power'), + time_remaining: vehicle.getCapabilityValue('measure_vehicleChargeTimeRemaining'), + odometer: vehicle.getCapabilityValue('measure_vehicleOdometer'), + range: vehicle.getCapabilityValue('measure_vehicleRange'), + service: vehicle.getCapabilityValue('alarm_generic'), + }; + }, + + // Handler for GET / + async getVehicles({ homey, body }) { + const driver = await homey.drivers.getDriver('vehicle'); + return driver.getDevices(); + } +}; +``` + +### Handler Function Parameters + +| Parameter | Description | +|-----------|-------------| +| `homey` | The Homey instance for accessing drivers, settings, etc. | +| `query` | Query string parameters (for GET requests) | +| `body` | Request body (for POST requests) | + +## 3. Calling the API from Widget HTML + +### Basic API Call + +```javascript +Homey.api('GET', '/status?registration=ABC123') + .then(response => { + console.log(response); + }) + .catch(err => { + console.error(err); + }); +``` + +### Full Widget Example + +```html + +``` + +## 4. Homey API Methods Reference + +### `Homey.api(method, path, [body])` +Make an API call to the widget backend. + +```javascript +// GET request with query params +Homey.api('GET', '/status?id=123') + +// POST request with body +Homey.api('POST', '/action', { command: 'start' }) +``` + +### `Homey.ready()` +Signal that the widget is ready to receive data. **Must be called** in `onHomeyReady`. + +```javascript +function onHomeyReady(Homey) { + Homey.ready(); +} +``` + +### `Homey.getSettings()` +Get widget settings configured by the user. + +```javascript +const { device } = Homey.getSettings(); +``` + +### `Homey.on(event, callback)` +Listen for real-time events from the app. + +```javascript +Homey.on('updatevehicle', () => { + syncStatus(); +}); +``` + +### `Homey.emit(event, data)` (for pair/repair pages) +Emit events to the driver. Used in pairing flows. + +```javascript +Homey.emit('testlogin', { username, password }) + .then(result => { + // Handle success + }); +``` + +## 5. Real-Time Updates + +### Broadcasting from Device/App + +From `device.js` or `app.js`, broadcast updates to widgets: + +```javascript +// In device.js +this.homey.api.realtime('updatevehicle'); + +// In app.js +this.homey.api.realtime('debugLog', { message: 'Something happened' }); +``` + +### Listening in Widget + +```javascript +Homey.on('updatevehicle', () => { + // Refresh widget data + syncStatus(); +}); +``` + +## 6. Widget Settings Autocomplete + +Register autocomplete handlers in `app.js`: + +```javascript +class MyApp extends Homey.App { + async onInit() { + this.homey.dashboards + .getWidget('dashboard') + .registerSettingAutocompleteListener('device', async (query, settings) => { + const driver = await this.homey.drivers.getDriver('vehicle'); + const devices = await driver.getDevices(); + + return devices + .map(device => ({ + name: device.getName(), + registration: device.getData().registration, + })) + .filter(v => v.name.toLowerCase().includes(query.toLowerCase())); + }); + } +} +``` + +## 7. Available Device Capabilities + +Access these via `vehicle.getCapabilityValue()`: + +| Capability | Type | Description | +|-----------|------|-------------| +| `measure_polestarBattery` | number (0-100) | Battery percentage | +| `measure_vehicleConnected` | boolean | Charger connected | +| `measure_vehicleChargeState` | boolean | Currently charging | +| `measure_current` | number | Charging current (A) | +| `measure_power` | number | Charging power (W) | +| `measure_vehicleChargeTimeRemaining` | number | Minutes to full | +| `measure_vehicleOdometer` | number | Odometer (km) | +| `measure_vehicleRange` | number | Estimated range (km) | +| `alarm_generic` | boolean | Service warning | + +## 8. Error Handling + +Always handle errors in API calls: + +```javascript +Homey.api('GET', '/status?registration=ABC') + .then(status => { + renderStatus(status); + }) + .catch(err => { + // Show user-friendly error + document.getElementById('error').innerText = err.message; + }); +``` + +In API handlers, throw errors to return them to the widget: + +```javascript +async getVehicleStatus({ homey, query }) { + if (!query.registration) { + throw new Error('Missing registration parameter'); + } + // ... +} +``` + +## 9. Complete Example: Adding a New Endpoint + +### Step 1: Define in widget.compose.json + +```json +{ + "api": { + "setChargeLimit": { + "method": "POST", + "path": "/charge-limit" + } + } +} +``` + +### Step 2: Implement in api.js + +```javascript +module.exports = { + async setChargeLimit({ homey, body }) { + const { registration, limit } = body; + const vehicle = await getVehicle({ homey, registration }); + + // Perform action + await vehicle.setCapabilityValue('target_charge_level', limit); + + return { success: true, newLimit: limit }; + } +}; +``` + +### Step 3: Call from widget HTML + +```javascript +Homey.api('POST', '/charge-limit', { + registration: device.registration, + limit: 80 +}) + .then(result => { + console.log('Charge limit set to', result.newLimit); + }); +``` + +## Key Files in This Project + +- [widgets/dashboard/widget.compose.json](widgets/dashboard/widget.compose.json) - Widget configuration +- [widgets/dashboard/api.js](widgets/dashboard/api.js) - API handlers +- [widgets/dashboard/public/index.html](widgets/dashboard/public/index.html) - Widget HTML +- [app.js](app.js) - App initialization & autocomplete registration +- [drivers/vehicle/device.js](drivers/vehicle/device.js) - Device capabilities & realtime broadcasts diff --git a/.gitignore b/.gitignore index 1fb5f1d..3354fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ drivers/polestar-2/* drivers/polestar-2-beta/* .homeyignore -/env.json \ No newline at end of file +/env_secret.json +tmpclaude-7720-cwd diff --git a/.homeychangelog.json b/.homeychangelog.json index 09f34c3..9c32ae9 100644 --- a/.homeychangelog.json +++ b/.homeychangelog.json @@ -70,5 +70,101 @@ }, "2.1.2": { "en": "Removed summary images from the device" + }, + "2.2.0": { + "en": "Added support for charge speed details" + }, + "2.3.0": { + "en": "Changed polestar vehicle to new device class car" + }, + "2.3.1": { + "en": "Fix for trigger events" + }, + "2.3.2": { + "en": "hotfix on the csv driver" + }, + "2.3.3": { + "en": "Improved insights on power measurements" + }, + "2.3.4": { + "en": "update to fix api changes" + }, + "2.3.5": { + "en": "Update npm modules" + }, + "2.4.0": { + "en": "Widget, service warning and login bug fix" + }, + "2.4.1": { + "en": "preview images" + }, + "2.4.2": { + "en": "fix widget update on device state change" + }, + "2.4.3": { + "en": "stability improvement" + }, + "2.4.4": { + "en": "service warning improvement" + }, + "2.4.5": { + "en": "Update preview of widget" + }, + "2.4.6": { + "en": "Fix for another api change on polestar side" + }, + "2.5.0": { + "en": "Added power meter with estimated overall usage" + }, + "2.5.1": { + "en": "Attempt to fix new api issue" + }, + "2.6.0": { + "en": "Authentication fix and new capabilities" + }, + "2.6.1": { + "en": "Another login fix" + }, + "2.6.2": { + "en": "Fine tune the login error message" + }, + "2.7.1": { + "en": "Added support for new ev charging states" + }, + "2.7.2": { + "en": "Fixed CSV receiver driver" + }, + "2.7.3": { + "en": "Added upgrade path for existing cars for homey energy" + }, + "2.7.4": { + "en": "More rebost energy upgrade detection" + }, + "2.7.5": { + "en": "Changed api, no linger supports images" + }, + "2.7.6": { + "en": "Changed api, no linger supports images" + }, + "2.7.7": { + "en": "Added support to switch to miles" + }, + "2.7.8": { + "en": "Rounding and stability improvements" + }, + "3.0.0": { + "en": "Migrate vehicle driver to Polestar C3 gRPC backend" + }, + "3.0.1": { + "en": "Polish on icons etc" + }, + "3.0.2": { + "en": "Fix 'Device Not Found' errors when unpairing or restarting the app while a poll was in flight" + }, + "3.0.3": { + "en": "Completed charge sessions now report 'Plugged in' instead of 'Paused'. Improved Find-my-car flow-card title. Added a 'Start climate (defaults)' flow action that mirrors the Polestar app's one-tap climate button." + }, + "3.0.4": { + "en": "Fix crash in the legacy Car Stats Viewer driver when a driving point arrived without altitude or state-of-charge data. Trip-ended flow now reports 'Unavailable' for missing fields instead of failing." } } diff --git a/.homeycompose/app.json b/.homeycompose/app.json index edcabdc..92ec41c 100644 --- a/.homeycompose/app.json +++ b/.homeycompose/app.json @@ -1,18 +1,20 @@ { "id": "com.Coderax.Polestar", - "version": "2.1.2", - "compatibility": ">=5.0.0", + "version": "3.0.4", + "compatibility": ">=12.4.5", "sdk": 3, "platforms": [ "local" ], "name": { "en": "Polestar", - "no": "Polestar" + "no": "Polestar", + "nl": "Polestar" }, "description": { - "en": "Enjoy the road with Polestar and Homey – smart technology for smart drivers", - "no": "Nyt veien med Polestar og Homey – smart teknologi for smarte sjåfører" + "en": "Monitor and control your Polestar from Homey — charging, climate, locks, windows, and more.", + "no": "Overvåk og styr Polestar fra Homey — lading, klima, låser, vinduer og mer.", + "nl": "Monitor en bedien je Polestar vanuit Homey — laden, climate, sloten, ramen en meer." }, "category": "internet", "permissions": [], @@ -22,20 +24,22 @@ "xlarge": "/assets/images/xlarge.png" }, "author": { - "name": "Jesper Grimstad", - "email": "jesper.grimstad@hotmail.com" + "name": "Vincent Boer", + "email": "vincent+homey@vdboer.nl" }, "brandColor": "#081822", - "support": "mailto:polestar@coderax.dev?subject=Polestar%20-%20Homey%20app", - "contributing": { - "donate": { - "paypal": { - "username": "Coderaxxx" + "support": "mailto:vincent+polestar@vdboer.nl?subject=Polestar%20-%20Homey%20app", + "contributors": { + "developers": [ + { + "name": "Jesper Grimstad", + "email": "jesper.grimstad@hotmail.com" }, - "githubSponsors": { - "username": "Coderaxx" + { + "name": "Vincent Boer", + "email": "vincent+homey@vdboer.nl" } - } + ] }, "tags": { "en": [ @@ -43,28 +47,68 @@ "pole", "star", "car", + "ev", "electric", "electric car", - "ev", "ev car", - "smart car" + "smart car", + "bev", + "charging", + "charge limit", + "precondition", + "climate", + "preheat", + "lock", + "unlock", + "remote", + "honk", + "flash" ], "no": [ "polestar", "pole", "star", - "car", - "electric", - "electric car", + "bil", + "elbil", + "elektrisk", + "smart bil", + "lading", + "ladegrense", + "forvarming", + "klima", + "lås", + "lås opp", + "fjernstyring", + "tut", + "blink" + ], + "nl": [ + "polestar", + "pole", + "star", + "auto", "ev", - "ev car", - "smart car" + "elektrisch", + "electrische auto", + "ev auto", + "bev", + "bev auto", + "slimme auto", + "batterij auto", + "laden", + "laadlimiet", + "voorverwarmen", + "climate", + "vergrendelen", + "ontgrendelen", + "afstandsbediening", + "toeteren", + "knipperen" ] }, "homeyCommunityTopicId": 95083, - "homepage": "https://coderax.dev", - "source": "https://github.com/Coderaxx/Polestar", + "source": "https://github.com/kaohlive/Polestar", "bugs": { - "url": "https://github.com/Coderaxx/Polestar/issues" + "url": "https://github.com/kaohlive/Polestar/issues" } } \ No newline at end of file diff --git a/.homeycompose/capabilities/alarm_polestarOtaAvailable.json b/.homeycompose/capabilities/alarm_polestarOtaAvailable.json new file mode 100644 index 0000000..baa1dd1 --- /dev/null +++ b/.homeycompose/capabilities/alarm_polestarOtaAvailable.json @@ -0,0 +1,13 @@ +{ + "type": "boolean", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "Software update available", + "no": "Programvareoppdatering tilgjengelig", + "nl": "Software-update beschikbaar" + }, + "getable": true, + "setable": false, + "insights": true +} diff --git a/.homeycompose/capabilities/alarm_polestarTyrePressure.json b/.homeycompose/capabilities/alarm_polestarTyrePressure.json new file mode 100644 index 0000000..846384b --- /dev/null +++ b/.homeycompose/capabilities/alarm_polestarTyrePressure.json @@ -0,0 +1,13 @@ +{ + "type": "boolean", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/tire-pressure.svg", + "title": { + "en": "Tyre pressure warning", + "no": "Dekktrykk-varsel", + "nl": "Bandenspanningswaarschuwing" + }, + "getable": true, + "setable": false, + "insights": true +} diff --git a/.homeycompose/capabilities/measure_polestarAlt.json b/.homeycompose/capabilities/measure_polestarAlt.json index acadf49..024fb4d 100644 --- a/.homeycompose/capabilities/measure_polestarAlt.json +++ b/.homeycompose/capabilities/measure_polestarAlt.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/altitude.svg", "title": { "en": "Altitude", - "no": "Høyde" + "no": "Høyde", + "nl": "Hoogte" }, "units": "m", "getable": true, diff --git a/.homeycompose/capabilities/measure_polestarBattery.json b/.homeycompose/capabilities/measure_polestarBattery.json index bc7a96a..a96a856 100644 --- a/.homeycompose/capabilities/measure_polestarBattery.json +++ b/.homeycompose/capabilities/measure_polestarBattery.json @@ -1,10 +1,11 @@ { "type": "number", "uiComponent": "sensor", - "icon": "/drivers/polestar-2-csv/assets/images/battery-75.svg", + "icon": "/drivers/vehicle/assets/battery-75.svg", "title": { "en": "Battery", - "no": "Batteri" + "no": "Batteri", + "nl": "Batterij" }, "units": "%", "getable": true, diff --git a/.homeycompose/capabilities/measure_polestarBatteryLevel.json b/.homeycompose/capabilities/measure_polestarBatteryLevel.json index 57f00c8..0b590ca 100644 --- a/.homeycompose/capabilities/measure_polestarBatteryLevel.json +++ b/.homeycompose/capabilities/measure_polestarBatteryLevel.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/hvbattery.svg", "title": { "en": "Battery level", - "no": "Batterinivå" + "no": "Batterinivå", + "nl": "Batterij niveau" }, "units": "kWh", "decimals": 2, diff --git a/.homeycompose/capabilities/measure_polestarChargeState.json b/.homeycompose/capabilities/measure_polestarChargeState.json index 0f1dae0..53b19a2 100644 --- a/.homeycompose/capabilities/measure_polestarChargeState.json +++ b/.homeycompose/capabilities/measure_polestarChargeState.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/charging.svg", "title": { "en": "Charging", - "no": "Lader" + "no": "Lader", + "nl": "Laden" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarChargingType.json b/.homeycompose/capabilities/measure_polestarChargingType.json new file mode 100644 index 0000000..b31f8ed --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarChargingType.json @@ -0,0 +1,13 @@ +{ + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/powerdc.svg", + "title": { + "en": "Charging type", + "no": "Ladetype", + "nl": "Laadtype" + }, + "getable": true, + "setable": false, + "insights": false +} diff --git a/.homeycompose/capabilities/measure_polestarClimateRemaining.json b/.homeycompose/capabilities/measure_polestarClimateRemaining.json new file mode 100644 index 0000000..4488902 --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarClimateRemaining.json @@ -0,0 +1,15 @@ +{ + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "Climate time remaining", + "no": "Klima-tid igjen", + "nl": "Climate resterend" + }, + "units": "min", + "decimals": 0, + "getable": true, + "setable": false, + "insights": true +} diff --git a/.homeycompose/capabilities/measure_polestarConnected.json b/.homeycompose/capabilities/measure_polestarConnected.json index b6860ce..9f017eb 100644 --- a/.homeycompose/capabilities/measure_polestarConnected.json +++ b/.homeycompose/capabilities/measure_polestarConnected.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/ccs.svg", "title": { "en": "Charge port", - "no": "Ladeport" + "no": "Ladeport", + "nl": "Laadpoort" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarDrivingKwh.json b/.homeycompose/capabilities/measure_polestarDrivingKwh.json new file mode 100644 index 0000000..5d63b3a --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarDrivingKwh.json @@ -0,0 +1,15 @@ +{ + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/hvbattery.svg", + "title": { + "en": "Driving energy (total)", + "no": "Kjøreenergi (totalt)", + "nl": "Rijverbruik (totaal)" + }, + "units": "kWh", + "decimals": 1, + "getable": true, + "setable": false, + "insights": true +} diff --git a/.homeycompose/capabilities/measure_polestarGear.json b/.homeycompose/capabilities/measure_polestarGear.json index 6706146..36c3e18 100644 --- a/.homeycompose/capabilities/measure_polestarGear.json +++ b/.homeycompose/capabilities/measure_polestarGear.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/gear.svg", "title": { "en": "Selected Gear", - "no": "Valgt gir" + "no": "Valgt gir", + "nl": "Geselecteerde versnelling" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarIgnitionState.json b/.homeycompose/capabilities/measure_polestarIgnitionState.json index 25bf5f0..6a01ee6 100644 --- a/.homeycompose/capabilities/measure_polestarIgnitionState.json +++ b/.homeycompose/capabilities/measure_polestarIgnitionState.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/ignition.svg", "title": { "en": "Ignition", - "no": "Tenning" + "no": "Tenning", + "nl": "Ontsteking" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarLocation.json b/.homeycompose/capabilities/measure_polestarLocation.json index 0217706..015e2df 100644 --- a/.homeycompose/capabilities/measure_polestarLocation.json +++ b/.homeycompose/capabilities/measure_polestarLocation.json @@ -1,10 +1,11 @@ { "type": "string", "uiComponent": "sensor", - "icon": "/drivers/polestar-2-csv/assets/images/location.svg", + "icon": "/drivers/vehicle/assets/location.svg", "title": { "en": "Location", - "no": "Plassering" + "no": "Plassering", + "nl": "Locatie" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarMonthlyCharge.json b/.homeycompose/capabilities/measure_polestarMonthlyCharge.json index c319856..4457028 100644 --- a/.homeycompose/capabilities/measure_polestarMonthlyCharge.json +++ b/.homeycompose/capabilities/measure_polestarMonthlyCharge.json @@ -4,7 +4,8 @@ "icon": "/drivers/vehicle/assets/charging.svg", "title": { "en": "Charged home this month", - "no": "Ladet hjemme denne mnd" + "no": "Ladet hjemme denne mnd", + "nl": "Deze maand thuis geladen" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarOtaState.json b/.homeycompose/capabilities/measure_polestarOtaState.json new file mode 100644 index 0000000..1bfcf49 --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarOtaState.json @@ -0,0 +1,13 @@ +{ + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "OTA state", + "no": "OTA-status", + "nl": "OTA-status" + }, + "getable": true, + "setable": false, + "insights": false +} diff --git a/.homeycompose/capabilities/measure_polestarOtaVersion.json b/.homeycompose/capabilities/measure_polestarOtaVersion.json new file mode 100644 index 0000000..9f86cac --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarOtaVersion.json @@ -0,0 +1,13 @@ +{ + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "New software version", + "no": "Ny programvareversjon", + "nl": "Nieuwe softwareversie" + }, + "getable": true, + "setable": false, + "insights": false +} diff --git a/.homeycompose/capabilities/measure_polestarPower.json b/.homeycompose/capabilities/measure_polestarPower.json index 368dfcb..fffc9ee 100644 --- a/.homeycompose/capabilities/measure_polestarPower.json +++ b/.homeycompose/capabilities/measure_polestarPower.json @@ -5,7 +5,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/powerdc.svg", "title": { "en": "Power", - "no": "Effekt" + "no": "Effekt", + "nl": "Acceleratie" }, "units": "kW", "getable": true, diff --git a/.homeycompose/capabilities/measure_polestarRange.json b/.homeycompose/capabilities/measure_polestarRange.json index 738913b..e0d23e7 100644 --- a/.homeycompose/capabilities/measure_polestarRange.json +++ b/.homeycompose/capabilities/measure_polestarRange.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/range.svg", "title": { "en": "Range", - "no": "Rekkevidde" + "no": "Rekkevidde", + "nl": "Bereik" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_polestarSessionKwh.json b/.homeycompose/capabilities/measure_polestarSessionKwh.json new file mode 100644 index 0000000..0047f5e --- /dev/null +++ b/.homeycompose/capabilities/measure_polestarSessionKwh.json @@ -0,0 +1,15 @@ +{ + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/charging.svg", + "title": { + "en": "Session charge", + "no": "Øktens lading", + "nl": "Laadsessie" + }, + "units": "kWh", + "decimals": 2, + "getable": true, + "setable": false, + "insights": true +} diff --git a/.homeycompose/capabilities/measure_polestarSpeed.json b/.homeycompose/capabilities/measure_polestarSpeed.json index 4aedebf..1aa52bf 100644 --- a/.homeycompose/capabilities/measure_polestarSpeed.json +++ b/.homeycompose/capabilities/measure_polestarSpeed.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/speedometer.svg", "title": { "en": "Speed", - "no": "Fart" + "no": "Fart", + "nl": "Snelheid" }, "units": { "en": "km/h", diff --git a/.homeycompose/capabilities/measure_polestarTemp.json b/.homeycompose/capabilities/measure_polestarTemp.json index 586f21a..40833f6 100644 --- a/.homeycompose/capabilities/measure_polestarTemp.json +++ b/.homeycompose/capabilities/measure_polestarTemp.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/temp.svg", "title": { "en": "Ambient Temperature", - "no": "Utetemperatur" + "no": "Utetemperatur", + "nl": "Buitentemperatuur" }, "units": "°C", "getable": true, diff --git a/.homeycompose/capabilities/measure_polestarUpdated.json b/.homeycompose/capabilities/measure_polestarUpdated.json index dafbdb0..e3f323d 100644 --- a/.homeycompose/capabilities/measure_polestarUpdated.json +++ b/.homeycompose/capabilities/measure_polestarUpdated.json @@ -4,7 +4,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/update.svg", "title": { "en": "Updated", - "no": "Oppdatert" + "no": "Oppdatert", + "nl": "Bijgewerkt" }, "getable": true, "setable": false, diff --git a/.homeycompose/capabilities/measure_vehicleConnected.json b/.homeycompose/capabilities/measure_vehicleConnected.json index 34496d6..0998cd1 100644 --- a/.homeycompose/capabilities/measure_vehicleConnected.json +++ b/.homeycompose/capabilities/measure_vehicleConnected.json @@ -2,7 +2,7 @@ "type": "boolean", "title": { "en": "Chargeport connected", - "no": "Ladepoort tilkoblet", + "no": "Ladeport tilkoblet", "nl": "Laadpoort verbonden" }, "getable": true, diff --git a/.homeycompose/capabilities/measure_vehicleDaysTillService.json b/.homeycompose/capabilities/measure_vehicleDaysTillService.json new file mode 100644 index 0000000..847d25a --- /dev/null +++ b/.homeycompose/capabilities/measure_vehicleDaysTillService.json @@ -0,0 +1,11 @@ +{ + "type": "number", + "title": { + "en": "Days till service", + "no": "Dager til service", + "nl": "Dagen tot service" + }, + "getable": true, + "setable": false, + "icon": "/drivers/vehicle/assets/timeremaining.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/measure_vehicleDistanceTillService.json b/.homeycompose/capabilities/measure_vehicleDistanceTillService.json new file mode 100644 index 0000000..18bc48e --- /dev/null +++ b/.homeycompose/capabilities/measure_vehicleDistanceTillService.json @@ -0,0 +1,14 @@ +{ + "type": "number", + "title": { + "en": "Distance till service", + "no": "Avstand til service", + "nl": "Afstand tot service" + }, + "getable": true, + "setable": false, + "units": { + "en": "KM" + }, + "icon": "/drivers/vehicle/assets/odometer.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/target_polestarAmpLimit.json b/.homeycompose/capabilities/target_polestarAmpLimit.json new file mode 100644 index 0000000..33a6018 --- /dev/null +++ b/.homeycompose/capabilities/target_polestarAmpLimit.json @@ -0,0 +1,18 @@ +{ + "type": "number", + "uiComponent": "slider", + "icon": "/drivers/vehicle/assets/charging.svg", + "title": { + "en": "Charging amp limit", + "no": "Amperegrense ved lading", + "nl": "Ampère-limiet bij laden" + }, + "units": "A", + "decimals": 0, + "min": 6, + "max": 32, + "step": 1, + "getable": true, + "setable": true, + "insights": true +} diff --git a/.homeycompose/capabilities/target_polestarChargeLimit.json b/.homeycompose/capabilities/target_polestarChargeLimit.json new file mode 100644 index 0000000..231af87 --- /dev/null +++ b/.homeycompose/capabilities/target_polestarChargeLimit.json @@ -0,0 +1,18 @@ +{ + "type": "number", + "uiComponent": "slider", + "icon": "/drivers/vehicle/assets/battery-100.svg", + "title": { + "en": "Charge limit", + "no": "Ladegrense", + "nl": "Laadlimiet" + }, + "units": "%", + "decimals": 0, + "min": 50, + "max": 100, + "step": 5, + "getable": true, + "setable": true, + "insights": true +} diff --git a/.homeycompose/flow/triggers/chargeportconnected_false.json b/.homeycompose/flow/triggers/chargeportconnected_false.json deleted file mode 100644 index bb8748b..0000000 --- a/.homeycompose/flow/triggers/chargeportconnected_false.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": { - "en": "Car disconnected from a charger", - "no": "Bil frakoblet lader" - }, - "hint": { - "en": "When the car detects it is no longer connected to a charge port", - "no": "Når bilen oppdager at ladepunktet er frakoblet" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ] -} \ No newline at end of file diff --git a/.homeycompose/flow/triggers/chargeportconnected_true.json b/.homeycompose/flow/triggers/chargeportconnected_true.json deleted file mode 100644 index b5aba39..0000000 --- a/.homeycompose/flow/triggers/chargeportconnected_true.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": { - "en": "Car connected to a charger", - "no": "Bil tilkoblet lader" - }, - "hint": { - "en": "When the car detects a charger connected to a charge port", - "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ] -} \ No newline at end of file diff --git a/.homeycompose/flow/triggers/charging_false.json b/.homeycompose/flow/triggers/charging_false.json deleted file mode 100644 index f3ebbaf..0000000 --- a/.homeycompose/flow/triggers/charging_false.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": { - "en": "Car stopped charging", - "no": "Lading stoppet" - }, - "hint": { - "en": "When the car stopped drawing power from the socket", - "no": "Når bilen sluttet å trekke strøm fra laderen" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ] -} \ No newline at end of file diff --git a/.homeycompose/flow/triggers/charging_true.json b/.homeycompose/flow/triggers/charging_true.json deleted file mode 100644 index d2a699d..0000000 --- a/.homeycompose/flow/triggers/charging_true.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": { - "en": "Car started charging", - "no": "Lading startet" - }, - "hint": { - "en": "When the car started to draw power from the socket", - "no": "Når bilen startet å trekke strøm fra laderen" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ] -} \ No newline at end of file diff --git a/API_FIELD_DISCOVERY_REPORT.md b/API_FIELD_DISCOVERY_REPORT.md new file mode 100644 index 0000000..c93171b --- /dev/null +++ b/API_FIELD_DISCOVERY_REPORT.md @@ -0,0 +1,244 @@ +# Polestar API Field Discovery Report + +**Date:** January 2025 +**API Endpoint:** `https://pc-api.polestar.com/eu-north-1/mystar-v2/` +**Test Vehicle:** Polestar 4 (2026 Model Year) + +## Executive Summary + +The Polestar GraphQL API has been significantly simplified and locked down. Many fields that were previously available (or referenced in older implementations) have been removed. GraphQL introspection is disabled, preventing automatic schema discovery. + +## ✅ Currently Available Fields + +### Vehicle Information (`getConsumerCarsV2`) +```graphql +query getCars { + getConsumerCarsV2 { + vin + internalVehicleIdentifier + modelYear + content { + model { + code + name + } + } + hasPerformancePackage + registrationNo + deliveryDate + currentPlannedDeliveryDate + } +} +``` + +**Note:** The `images` field has been removed from the API (previously contained `studio.url` and `studio.angles`). + +### Telematics Data (`carTelematicsV2`) + +#### Battery Information +```graphql +battery { + vin + batteryChargeLevelPercentage + chargingStatus + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + estimatedDistanceToEmptyMiles + timestamp { + seconds + nanos + } +} +``` + +**Available Charging Statuses:** +- `CHARGING_STATUS_IDLE` +- `CHARGING_STATUS_CHARGING` +- `CHARGING_STATUS_DONE` +- `CHARGING_STATUS_SCHEDULED` +- `CHARGING_STATUS_SMART_CHARGING` +- `CHARGING_STATUS_ERROR` +- `CHARGING_STATUS_FAULT` + +#### Odometer Information +```graphql +odometer { + vin + odometerMeters + timestamp { + seconds + nanos + } +} +``` + +#### Health Information +```graphql +health { + vin + brakeFluidLevelWarning + daysToService + distanceToServiceKm + engineCoolantLevelWarning + oilLevelWarning + serviceWarning + timestamp { + seconds + nanos + } +} +``` + +## ❌ Unavailable Fields (Tested & Confirmed) + +### Battery Fields (No Longer Available) +- `chargingCurrentAmps` - Charging current in amperes +- `chargingPowerWatts` - Charging power in watts +- `averageEnergyConsumptionKwhPer100Km` - Energy consumption average +- `chargerConnectionStatus` - Whether charger is physically connected +- `estimatedChargingTimeMinutesToTargetDistance` - Time to target range + +### Odometer Fields (No Longer Available) +- `averageSpeedKmPerHour` - Average vehicle speed +- `tripMeterAutomaticKm` - Automatic trip meter +- `tripMeterManualKm` - Manual trip meter + +### Health Fields (No Longer Available) +- `washerFluidLevelWarning` - Washer fluid level warning + +### Telematics Categories (Not Available) +- **Location Data** - No GPS coordinates, latitude, longitude, or heading + - Tested: `location`, `vehicleLocation`, `getCarLocation`, `getConsumerCarByVin` with location fields +- **Climate Data** - No HVAC or temperature information + - Tested: `climate` with `climateStatus`, `targetTemperature`, `interiorTemperature` +- **Lock Status** - No door lock information + - Tested: `locks` with various door lock fields +- **Window Status** - No window open/close status + - Tested: `windows` with window status fields + +### Old API Queries (Deprecated) +These queries worked in older implementations but no longer function: +- `getBatteryData` - Old battery data query +- `getOdometerData` - Old odometer query with trip meters +- `carTelematics` (non-V2) - Original telematics query +- `getChargingConnectionStatus` - Charging connection details + +## 📊 API Changes Timeline + +Based on research of other projects: + +- **Pre-January 2024:** Old API (`/my-star`) with extensive fields including: + - Charging power and current + - Trip meters + - Average speed + - Energy consumption + +- **January 2024:** API migration to `/mystar-v2` + - Many fields removed + - `getBatteryData` and `getOdometerData` deprecated + - Switch to `carTelematicsV2` with limited fields + +- **Current (January 2025):** Further restrictions + - Images removed from vehicle data + - No location data available via API + - No charging power/amperage data + - Introspection disabled + +## 🔍 Research Sources + +Investigation included: +1. **pypolestar/polestar_api** - Python Home Assistant integration +2. **evcc-io/evcc** - EV charging control system +3. **Direct API testing** - 15+ different query combinations tested +4. **GraphQL introspection** - Attempted but disabled by Polestar + +## 💡 Workarounds for Missing Data + +### Location Data +**Problem:** No GPS coordinates available via API +**Workaround:** Install Home Assistant app in Android Automotive (vehicle's built-in system) and use device_tracker entity. This requires: +- Home Assistant installation +- Nabu Casa account (optional but recommended) +- Permission to share location from vehicle + +### Charging Power/Current +**Problem:** No charging watts or amps available +**Workaround:** None found. This data was previously available but has been removed from the API. + +### Trip Meters & Average Speed +**Problem:** Not available in current API +**Workaround:** Calculate manually using odometer readings over time (less accurate). + +## 🎯 Recommendations + +### For Your Homey App + +1. **Remove commented-out code** in `device.js` lines 136-149: + - `chargingCurrentAmps` will never return data + - `chargingPowerWatts` will never return data + - `chargerConnectionStatus` is not available + +2. **Current implementation is optimal** - You're already using all available fields: + ```javascript + // Battery + batteryChargeLevelPercentage + chargingStatus + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + + // Odometer + odometerMeters + + // Health + brakeFluidLevelWarning + daysToService + distanceToServiceKm + engineCoolantLevelWarning + oilLevelWarning + serviceWarning + ``` + +3. **Connection detection workaround** - Your current logic (lines 155-171) correctly uses `chargingStatus` to infer connection: + ```javascript + const connectedStatuses = new Set([ + 'CHARGING_STATUS_CHARGING', + 'CHARGING_STATUS_DONE', + 'CHARGING_STATUS_SCHEDULED', + 'CHARGING_STATUS_SMART_CHARGING', + 'CHARGING_STATUS_ERROR', + 'CHARGING_STATUS_FAULT' + ]); + ``` + This is the best available approach since `chargerConnectionStatus` is gone. + +### For Future Development + +1. **Monitor for API changes** - Polestar has been changing the API periodically +2. **No additional fields can be added** - The API currently provides minimal data +3. **Location tracking requires alternative solution** - Consider Home Assistant integration if needed +4. **Energy monitoring limitations** - Without power/current data, detailed energy monitoring is not possible + +## 📝 Conclusion + +The Polestar API has evolved from a feature-rich interface to a minimal read-only API providing only basic telematics: +- ✅ Battery level and charging status +- ✅ Range estimates +- ✅ Odometer reading +- ✅ Basic service warnings +- ❌ No location data +- ❌ No charging power/current +- ❌ No trip meters or speed data +- ❌ No climate, lock, or window status + +**Your current implementation is using all available API fields.** The commented-out code in your device.js file represents features that were likely planned but are no longer possible due to API restrictions. + +## 🔗 References + +- GitHub: pypolestar/polestar_api (Python implementation) +- GitHub: evcc-io/evcc (Go implementation for EV charging) +- Polestar Forum discussions on API access +- Direct GraphQL API testing results (January 2025) + +--- + +*Report generated through systematic API field discovery and community research.* diff --git a/README.md b/README.md index 402210a..f1fabc9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,53 @@ -# Polestar +# Polestar for Homey -Adds support for Polestar statistics, by using Tibber API (account required, no subscription required). \ No newline at end of file +Unofficial Homey Pro integration for Polestar vehicles. Connects to Polestar's C3 +cloud backend (the same one the Polestar mobile app uses) to expose vehicle +status and remote-control commands as Homey capabilities and flow cards. + +## Supported vehicles + +- **Polestar 4** — tested +- **Polestar 3** — should work via the shared C3 backend (unverified) +- **Polestar 2** — should work via C3; feedback from P2 owners welcome + +Feature availability differs per model. The integration probes each service on +first use and automatically hides capabilities the car reports as +`UNIMPLEMENTED` (e.g. charging amperage limit is not exposed on Polestar 4). + +## What's included + +**Read**: battery level, charging status, power / current / voltage while +charging, session + lifetime kWh, range, odometer, interior + target +temperature, parking climatization state and time remaining, lock status, +per-door and per-closure open/closed alarms, tyre pressures (four wheels in +kPa), service warnings and distance-to-service, last known GPS location, OTA +software update state. + +**Write**: start/stop charging, set charge limit and amperage, lock/unlock, +unlock trunk, honk and flash, start/stop parking climatization (with +temperature, per-seat heating, steering-wheel heating), open/close all +windows. + +All writes are exposed as both device tiles and flow action cards. A device +setting provides a master-switch to disable writes globally, and optional +features can be hidden per-device when unsupported. + +## Configuration + +Pair a vehicle with your Polestar ID email and password. Homey handles OIDC +authentication automatically; tokens refresh transparently. + +## The `polestar-2-csv` driver + +The older *Car Stats Viewer* webhook driver is marked deprecated — the main +`vehicle` driver now covers all its battery/charging/range functionality and +more. Existing devices keep working; no new pairings are accepted. P2 owners +who rely on the webhook for real-time driving telemetry (speed, gear, ignition, +battery temperature, trip summaries) can keep using it alongside the main +driver. + +## Disclaimer + +This integration is unofficial and not affiliated with or endorsed by Polestar +or Volvo Cars. Use at your own risk. Authentication uses reverse-engineered +endpoints that may change or break without notice. diff --git a/README.no.txt b/README.no.txt deleted file mode 100644 index 49cb4c7..0000000 --- a/README.no.txt +++ /dev/null @@ -1,26 +0,0 @@ -Hei, Polestar-eier! 🚗✨ - -Velkommen til den oppgraderte Polestar-appen for Homey – din portal til en smartere kjøreopplevelse. Denne hendige hjelperen kobler ikke bare din elegante elbil til ditt smarte hjem, men bringer nå en rekke nye funksjoner rett til fingertuppene dine. 🏠🔌 - -Kjør smart, lev smartere! ⚡️ - -Hva er nytt i den siste oppdateringen: - -Direkteforbindelse med Homey: Vi introduserer "Car Stats Viewer", som kobler din Polestar med Homey som aldri før. 🚀 -Eksklusiv Tilgang: En spesiell 'intern testbane' for de første 100 brukerne for å oppleve de siste fremskrittene. 🏁 -Forbedrede Klassiske Funksjoner: - -Innsikt i Batteriet: Hold et øye med batterinivået i din Polestar – alltid klar for neste eventyr. 🔋 -Overvåkning av Ladestatus: Nyt den ekstra kaffekoppen mens bilen lader effektivt. ☕️ -Estimert Rekkevidde: Perfekt for både spontane utflukter og godt planlagte reiser. 🌍 -Oppdateringer på Bilens Tilstand: Din Polestar er ikke bare en bil; den er en del av livet ditt. ❤️ -Kom i Gang: - -Tibber-integrasjon: Ingen ekstra abonnement nødvendig. Bare koble til din Tibber-konto for sømløs integrering. -Webhook Magi: Sett opp en ny webhook i Homey for forbedret kommunikasjon med bilen din. -Enkel Installasjon: Bare send en e-post til polestar@coderax.dev med dine Google Play-detaljer for tilgang, installer appen og følg oppsettsveiledningen. 📲 -La oss Rulle inn i Fremtiden! 🎲 - -Legg til Polestar-appen i din Homey, følg de nye installasjonsinstruksjonene, og trå inn i en verden hvor din bil og hjem eksisterer i perfekt harmoni. - -Takk for at du valgte Polestar-appen – hvor teknologi møter veien, og ditt smarte hjem møter din smarte kjøretur. Sikre og smarte reiser! 🛣️✨ \ No newline at end of file diff --git a/README.txt b/README.txt index f3e8f87..52b5534 100644 --- a/README.txt +++ b/README.txt @@ -1,26 +1,46 @@ -Hello, Polestar Owner! 🚗✨ - -Welcome to the upgraded Polestar app for Homey, your gateway to a smarter driving experience. This nifty helper not only connects your sleek electric car to your smart home but now brings an array of new features to your fingertips. 🏠🔌 - -Drive Smart, Live Smarter! ⚡️ - -What’s New in the Latest Update: - -Direct Connection with Homey: Introducing the "Car Stats Viewer" feature, connecting your Polestar with Homey like never before. 🚀 -Exclusive Access: A special 'internal test track' for the first 100 users to experience the latest advancements. 🏁 -Classic Features Enhanced: - -Battery Insights: Keep an eye on your Polestar's battery level – always be ready for what's next. 🔋 -Charging Status Monitoring: Enjoy that extra cup of coffee while your car charges efficiently. ☕️ -Range Estimation: Perfect for both spontaneous outings and well-planned journeys. 🌍 -Car’s Health Updates: Your Polestar isn't just a car; it's part of your life. ❤️ -Getting Started: - -Tibber Integration: No extra subscription needed. Just link your Tibber account for seamless integration. -Webhook Magic: Set up a new webhook in Homey for enhanced communication with your car. -Easy Installation: Just email polestar@coderax.dev with your Google Play details for access, install the app, and follow the setup guide. 📲 -Let’s Roll into the Future! 🎲 - -Add the Polestar app to your Homey, follow the new setup instructions, and step into a world where your car and home exist in perfect harmony. - -Thank you for choosing the Polestar app – where technology meets the road, and your smart home meets your smart ride. Safe and smart travels! 🛣️✨ \ No newline at end of file +Hello, Polestar Owner! + +The Polestar app for Homey connects your car to your smart home via the same +cloud backend the official Polestar mobile app uses. Sign in once with your +Polestar ID and you're set — Homey handles authentication and keeps your +vehicle data fresh. + +WHAT YOU CAN SEE + +- State of charge, range, charging status (plugged in, charging, paused, idle) +- Charging power, current and voltage while a session is running +- Lifetime charging kWh, current-session kWh, total driving kWh +- Odometer and trip meters +- Interior temperature, climate target and minutes remaining +- Central lock status; per-door, per-window, trunk, hood, sunroof and charge- + port open alarms +- Tyre pressures (all four wheels, kPa) and tyre-pressure warning +- Service warnings, days and distance to next service +- Last known GPS coordinates +- Available software update state and version + +WHAT YOU CAN CONTROL + +- Lock and unlock the car +- Unlock the trunk only +- Honk horn, flash lights, or both — to find the car in a parking lot +- Start and stop charging (overrides the scheduled timer) +- Set the charge limit (50-100%) and, where supported, the charging amperage +- Start parking climatization with a chosen target temperature, per-seat + heating (front left, front right, rear left, rear right) and steering-wheel + heating +- Stop climatization +- Open or close all side windows (where supported) + +All controls are available as tiles on the device page AND as flow actions +with explicit arguments for flexible automations. Conditions cards let flows +branch on lock state, charge limit, and amperage limit. + +PRIVACY AND SAFETY + +A master switch in device settings disables every write command instantly — +useful while servicing the car or if an automation misbehaves. Optional +features the car reports as unsupported are removed from the UI +automatically (e.g. the amp-limit slider on Polestar 4). + +Enjoy the road with Polestar and Homey. diff --git a/app.js b/app.js index a1b4799..187fa9a 100644 --- a/app.js +++ b/app.js @@ -11,17 +11,46 @@ class Polestar extends Homey.App { this.homey.settings.set('debugLog', null); this.log(this.homey.__({ + nl: 'Polestar App is geinitialiseert', en: 'Polestar App has been initialized', no: 'Polestar App har blitt initialisert' })); + // Don't ever log credentials or tokens in plaintext. For sensitive keys we + // log the string length so we can still verify the user entered something. + const SENSITIVE_KEYS = new Set(['user_email', 'user_password', 'polestar_token']); this.homey.settings.on('set', (key) => { if (key === 'debugLog') return; + const value = this.homey.settings.get(key); + const display = SENSITIVE_KEYS.has(key) + ? (typeof value === 'string' && value.length > 0 ? `[${value.length} chars]` : '[empty]') + : value; this.log(this.homey.__({ + nl: 'Setting bijgewerkt:', en: 'Setting updated:', no: 'Innstilling oppdatert:' - }), 'Polestar App', 'DEBUG', `${key}: ${key == 'polestar_token' ? '********' : this.homey.settings.get(key)}`); + }), 'Polestar App', 'DEBUG', `${key}: ${display}`); }); + + try { + this.homey.dashboards + .getWidget('dashboard') + .registerSettingAutocompleteListener('device', async (query, settings) => { + this.log("List Polestar vehicles for widget settings") + const driver = await this.homey.drivers.getDriver('vehicle'); + const devices = await driver.getDevices(); + this.log('Located '+devices.length+' vehicles: filter using '+query) + this.log(devices[0].getData().registration) + return Object.values(devices) + .map(device => ({ + name: device.getName(), + registration: device.getData().registration, + })) + .filter(vehicle => vehicle.name.toLowerCase().includes(query.toLowerCase())); + }); + } catch (err) { + this.log(`Dashboards might not be available: ${err.message}`); + } } async log(message, instance = 'Polestar App', severity = 'DEBUG', data = null) { diff --git a/app.json b/app.json index 121d716..c33cea8 100644 --- a/app.json +++ b/app.json @@ -1,19 +1,21 @@ { "_comment": "This file is generated. Please edit .homeycompose/app.json instead.", "id": "com.Coderax.Polestar", - "version": "2.1.2", - "compatibility": ">=5.0.0", + "version": "3.0.4", + "compatibility": ">=12.4.5", "sdk": 3, "platforms": [ "local" ], "name": { "en": "Polestar", - "no": "Polestar" + "no": "Polestar", + "nl": "Polestar" }, "description": { - "en": "Enjoy the road with Polestar and Homey – smart technology for smart drivers", - "no": "Nyt veien med Polestar og Homey – smart teknologi for smarte sjåfører" + "en": "Monitor and control your Polestar from Homey — charging, climate, locks, windows, and more.", + "no": "Overvåk og styr Polestar fra Homey — lading, klima, låser, vinduer og mer.", + "nl": "Monitor en bedien je Polestar vanuit Homey — laden, climate, sloten, ramen en meer." }, "category": "internet", "permissions": [], @@ -23,20 +25,22 @@ "xlarge": "/assets/images/xlarge.png" }, "author": { - "name": "Jesper Grimstad", - "email": "jesper.grimstad@hotmail.com" + "name": "Vincent Boer", + "email": "vincent+homey@vdboer.nl" }, "brandColor": "#081822", - "support": "mailto:polestar@coderax.dev?subject=Polestar%20-%20Homey%20app", - "contributing": { - "donate": { - "paypal": { - "username": "Coderaxxx" - }, - "githubSponsors": { - "username": "Coderaxx" + "support": "mailto:vincent+polestar@vdboer.nl?subject=Polestar%20-%20Homey%20app", + "contributors": { + "developers": [ + { + "name": "Jesper Grimstad", + "email": "jesper.grimstad@hotmail.com" + }, + { + "name": "Vincent Boer", + "email": "vincent+homey@vdboer.nl" } - } + ] }, "tags": { "en": [ @@ -44,109 +48,118 @@ "pole", "star", "car", + "ev", "electric", "electric car", - "ev", "ev car", - "smart car" + "smart car", + "bev", + "charging", + "charge limit", + "precondition", + "climate", + "preheat", + "lock", + "unlock", + "remote", + "honk", + "flash" ], "no": [ "polestar", "pole", "star", - "car", - "electric", - "electric car", + "bil", + "elbil", + "elektrisk", + "smart bil", + "lading", + "ladegrense", + "forvarming", + "klima", + "lås", + "lås opp", + "fjernstyring", + "tut", + "blink" + ], + "nl": [ + "polestar", + "pole", + "star", + "auto", "ev", - "ev car", - "smart car" + "elektrisch", + "electrische auto", + "ev auto", + "bev", + "bev auto", + "slimme auto", + "batterij auto", + "laden", + "laadlimiet", + "voorverwarmen", + "climate", + "vergrendelen", + "ontgrendelen", + "afstandsbediening", + "toeteren", + "knipperen" ] }, "homeyCommunityTopicId": 95083, - "homepage": "https://coderax.dev", - "source": "https://github.com/Coderaxx/Polestar", + "source": "https://github.com/kaohlive/Polestar", "bugs": { - "url": "https://github.com/Coderaxx/Polestar/issues" + "url": "https://github.com/kaohlive/Polestar/issues" }, "flow": { "triggers": [ { + "id": "measure_polestarConnected_false", "title": { "en": "Car disconnected from a charger", - "no": "Bil frakoblet lader" + "no": "Bil frakoblet lader", + "nl": "Auto is niet meer verbonden met de lader" }, "hint": { "en": "When the car detects it is no longer connected to a charge port", - "no": "Når bilen oppdager at ladepunktet er frakoblet" + "no": "Når bilen oppdager at ladepunktet er frakoblet", + "nl": "Als de auto detecteerd dat de laadpoort niet meer verbonden is met een lader" }, "args": [ { - "name": "Vehicle", "type": "device", - "filter": "driver_id=vehicle" + "name": "device", + "filter": "driver_id=polestar-2-csv&capabilities=measure_polestarConnected" } - ], - "id": "chargeportconnected_false" + ] }, { + "id": "measure_polestarConnected_true", "title": { "en": "Car connected to a charger", - "no": "Bil tilkoblet lader" + "no": "Bil tilkoblet lader", + "nl": "Auto is verbonden met een lader" }, "hint": { "en": "When the car detects a charger connected to a charge port", - "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ], - "id": "chargeportconnected_true" - }, - { - "title": { - "en": "Car stopped charging", - "no": "Lading stoppet" - }, - "hint": { - "en": "When the car stopped drawing power from the socket", - "no": "Når bilen sluttet å trekke strøm fra laderen" - }, - "args": [ - { - "name": "Vehicle", - "type": "device", - "filter": "driver_id=vehicle" - } - ], - "id": "charging_false" - }, - { - "title": { - "en": "Car started charging", - "no": "Lading startet" - }, - "hint": { - "en": "When the car started to draw power from the socket", - "no": "Når bilen startet å trekke strøm fra laderen" + "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet", + "nl": "Wanneer de auto detecteerd dat de laadpoort verbonden is met een lader" }, "args": [ { - "name": "Vehicle", "type": "device", - "filter": "driver_id=vehicle" + "name": "device", + "filter": "driver_id=polestar-2-csv&capabilities=measure_polestarConnected" } - ], - "id": "charging_true" + ] }, { "id": "chargingStarted", "title": { "en": "Charging started", - "no": "Lading startet" + "no": "Lading startet", + "nl": "Laden is gestart" }, "args": [ { @@ -160,7 +173,8 @@ "id": "chargingEnded", "title": { "en": "Charging ended", - "no": "Lading stoppet" + "no": "Lading stoppet", + "nl": "Laden is gestopt" }, "args": [ { @@ -174,7 +188,8 @@ "id": "tripEnded", "title": { "en": "Trip ended", - "no": "Reisen er over" + "no": "Reisen er over", + "nl": "De rit is geindigd" }, "tokens": [ { @@ -182,11 +197,13 @@ "name": "lastTrip", "title": { "en": "Your last trip", - "no": "Din siste reise" + "no": "Din siste reise", + "nl": "Uw recente rit" }, "example": { "en": "An image visualizing your last trip, combined with useful trip data", - "no": "Et bilde som viser din siste reise, kombinert med nyttige reise-data" + "no": "Et bilde som viser din siste reise, kombinert med nyttige reise-data", + "nl": "Een visuele weergave van uw recente trip, gecombineerd met nuttige rit info" } }, { @@ -194,11 +211,13 @@ "name": "tripInfo", "title": { "en": "Trip Info", - "no": "Reiseinfo" + "no": "Reiseinfo", + "nl": "Rit info" }, "example": { "en": "An image visualizing data about your last trip", - "no": "Et bilde som viser data om din siste reise" + "no": "Et bilde som viser data om din siste reise", + "nl": "Een afbeelding met uw recente rit weergegeven" } }, { @@ -206,11 +225,13 @@ "name": "tripScore", "title": { "en": "Trip Score", - "no": "Reise-score" + "no": "Reise-score", + "nl": "Rit score" }, "example": { "en": "An image visualizing how well your last trip was", - "no": "Et bilde som viser hvor bra din siste reise var" + "no": "Et bilde som viser hvor bra din siste reise var", + "nl": "Een afbeelding over hoe goed uw rit was" } }, { @@ -218,11 +239,13 @@ "name": "tripFrom", "title": { "en": "Trip From", - "no": "Reise fra" + "no": "Reise fra", + "nl": "Rit vanaf" }, "example": { "en": "Storgata 1, Oslo", - "no": "Storgata 1, Oslo" + "no": "Storgata 1, Oslo", + "nl": "Storgata 1, Oslo" } }, { @@ -230,11 +253,13 @@ "name": "tripTo", "title": { "en": "Trip To", - "no": "Reise til" + "no": "Reise til", + "nl": "Rit naar" }, "example": { "en": "Karl Johans gate 1, Oslo", - "no": "Karl Johans gate 1, Oslo" + "no": "Karl Johans gate 1, Oslo", + "nl": "Karl Johans gate 1, Oslo" } }, { @@ -242,11 +267,13 @@ "name": "totalDistance", "title": { "en": "Total Distance", - "no": "Total distanse" + "no": "Total distanse", + "nl": "Totale afstand" }, "example": { "en": "2.5 km", - "no": "2.5 km" + "no": "2.5 km", + "nl": "2.5 km" } }, { @@ -254,11 +281,13 @@ "name": "dateString", "title": { "en": "Date", - "no": "Dato" + "no": "Dato", + "nl": "Datum" }, "example": { "en": "31.12.2023", - "no": "31.12.2023" + "no": "31.12.2023", + "nl": "31.12.2023" } }, { @@ -266,11 +295,13 @@ "name": "timeStringStart", "title": { "en": "Start Time", - "no": "Starttid" + "no": "Starttid", + "nl": "Start tijd" }, "example": { "en": "10:00", - "no": "10:00" + "no": "10:00", + "nl": "10:00" } }, { @@ -278,11 +309,13 @@ "name": "timeStringEnd", "title": { "en": "End Time", - "no": "Sluttid" + "no": "Sluttid", + "nl": "Eind tijd" }, "example": { "en": "10:30", - "no": "10:30" + "no": "10:30", + "nl": "10:30" } }, { @@ -290,11 +323,13 @@ "name": "tripDuration", "title": { "en": "Trip Duration", - "no": "Reisens varighet" + "no": "Reisens varighet", + "nl": "Ritduur" }, "example": { "en": "2.5 hours", - "no": "2.5 timer" + "no": "2.5 timer", + "nl": "2.5 timer" } }, { @@ -302,11 +337,13 @@ "name": "socStart", "title": { "en": "Start State of Charge", - "no": "Starttilstand for lading" + "no": "Starttilstand for lading", + "nl": "Startniveau van batterijniveau" }, "example": { "en": "80%", - "no": "80%" + "no": "80%", + "nl": "80%" } }, { @@ -314,11 +351,13 @@ "name": "socEnd", "title": { "en": "End State of Charge", - "no": "Slutttilstand for lading" + "no": "Slutttilstand for lading", + "nl": "Eindniveau van batterijniveau" }, "example": { "en": "50%", - "no": "50%" + "no": "50%", + "nl": "50%" } }, { @@ -326,11 +365,13 @@ "name": "energyUsed", "title": { "en": "Energy Used", - "no": "Energi brukt" + "no": "Energi brukt", + "nl": "Verbruikte energy" }, "example": { "en": "12.41 kWh", - "no": "12.41 kWh" + "no": "12.41 kWh", + "nl": "12.41 kWh" } }, { @@ -338,11 +379,13 @@ "name": "altStart", "title": { "en": "Start Altitude", - "no": "Start høyde" + "no": "Start høyde", + "nl": "Start hoogte" }, "example": { "en": "20 m", - "no": "20 m" + "no": "20 m", + "nl": "20 m" } }, { @@ -350,11 +393,13 @@ "name": "altEnd", "title": { "en": "End Altitude", - "no": "Slutt høyde" + "no": "Slutt høyde", + "nl": "Eind hoogte" }, "example": { "en": "40 m", - "no": "40 m" + "no": "40 m", + "nl": "40 m" } } ], @@ -370,7 +415,8 @@ "id": "measure_polestarPower_changed", "title": { "en": "Power changed", - "no": "Strøm endret" + "no": "Strøm endret", + "nl": "Stroom verandert" }, "tokens": [ { @@ -378,11 +424,13 @@ "type": "number", "title": { "en": "Power", - "no": "Strøm" + "no": "Strøm", + "nl": "Stroom" }, "example": { "en": "12.41 kW", - "no": "12.41 kW" + "no": "12.41 kW", + "nl": "12.41 kW" } } ], @@ -393,399 +441,1848 @@ "filter": "driver_id=polestar-2-csv" } ] - } - ] - }, - "drivers": [ - { - "name": { - "en": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)", - "no": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)" - }, - "class": "sensor", - "capabilities": [ - "measure_battery", - "measure_polestarIgnitionState", - "measure_polestarGear", - "measure_polestarSpeed", - "measure_polestarAlt", - "measure_polestarBattery", - "measure_polestarRange", - "measure_polestarBatteryLevel", - "measure_polestarPower", - "measure_polestarConnected", - "measure_polestarLocation", - "measure_polestarTemp", - "measure_polestarUpdated" - ], - "measure_power": { - "approximated": true }, - "platforms": [ - "local" - ], - "connectivity": [ - "cloud" - ], - "images": { - "small": "/drivers/polestar-2-csv/assets/images/small.png", - "large": "/drivers/polestar-2-csv/assets/images/large.png" + { + "id": "measure_vehicleChargeState_false", + "title": { + "en": "Car stopped charging", + "no": "Lading stoppet", + "nl": "Auto is gestopt met laden" + }, + "hint": { + "en": "When the car stopped drawing power from the socket", + "no": "Når bilen sluttet å trekke strøm fra laderen", + "nl": "Als de auto zelf gestopt is met het opnemen van stroom uit via laadpoort" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle&capabilities=measure_vehicleChargeState" + } + ] }, - "pair": [ - { - "id": "start", - "navigation": { - "next": "step2" + { + "id": "measure_vehicleChargeState_true", + "title": { + "en": "Car started charging", + "no": "Lading startet", + "nl": "Auto is begonnen met laden" + }, + "hint": { + "en": "When the car started to draw power from the socket", + "no": "Når bilen startet å trekke strøm fra laderen", + "nl": "Als de auto is begonnen met het opnemen van stroom via de laadpoort" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle&capabilities=measure_vehicleChargeState" } + ] + }, + { + "id": "measure_vehicleConnected_false", + "title": { + "en": "Car disconnected from a charger", + "no": "Bil frakoblet lader", + "nl": "Auto is niet meer verbonden met de lader" }, - { - "id": "step2", - "navigation": { - "prev": "start" + "hint": { + "en": "When the car detects it is no longer connected to a charge port", + "no": "Når bilen oppdager at ladepunktet er frakoblet", + "nl": "Als de auto detecteerd dat de laadpoort niet meer verbonden is met een lader" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle&capabilities=measure_vehicleConnected" } + ] + }, + { + "id": "measure_vehicleConnected_true", + "title": { + "en": "Car connected to a charger", + "no": "Bil tilkoblet lader", + "nl": "Auto is verbonden met een lader" }, - { - "id": "getPolestar" + "hint": { + "en": "When the car detects a charger connected to a charge port", + "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet", + "nl": "Wanneer de auto detecteerd dat de laadpoort verbonden is met een lader" }, - { - "id": "list_devices", - "template": "list_devices", - "options": { - "singular": true - }, - "navigation": { - "next": "add_device" + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle&capabilities=measure_vehicleConnected" } + ] + } + ], + "conditions": [ + { + "id": "is_locked", + "title": { + "en": "Car !{{is|is not}} locked", + "no": "Bil !{{er|er ikke}} låst", + "nl": "Auto !{{is|is niet}} vergrendeld" }, - { - "id": "add_device", - "template": "add_devices" - } - ], - "energy": { - "batteries": [ - "INTERNAL" + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } ] }, - "id": "polestar-2-csv", - "settings": [ - { - "type": "group", - "label": { - "en": "General settings", - "no": "Generelle innstillinger" + { + "id": "target_soc_is", + "title": { + "en": "Charge limit !{{is|is not}} at least", + "no": "Ladegrense !{{er|er ikke}} minst", + "nl": "Laadlimiet !{{is|is niet}} minimaal" + }, + "titleFormatted": { + "en": "Charge limit !{{is|is not}} at least [[level]]%", + "no": "Ladegrense !{{er|er ikke}} minst [[level]]%", + "nl": "Laadlimiet !{{is|is niet}} minimaal [[level]]%" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" }, - "children": [ - { - "id": "endTripThreshold", - "type": "number", - "value": 10, - "label": { - "en": "End trip threshold", - "no": "Terskel for endt tur" - }, - "hint": { - "en": "The time in minutes after which a trip should be considered ended. (Optional)", - "no": "Terskel i minutter for når en tur skal anses som avsluttet. (Valgfritt)" - } + { + "type": "number", + "name": "level", + "min": 50, + "max": 100, + "step": 1, + "placeholder": { + "en": "%", + "no": "%", + "nl": "%" } - ] + } + ] + }, + { + "id": "amp_limit_is", + "title": { + "en": "Amp limit !{{is|is not}} at least", + "no": "Amperebegrensning !{{er|er ikke}} minst", + "nl": "Amperebegrenzing !{{is|is niet}} minimaal" }, - { - "type": "group", - "label": { - "en": "Trip summary settings", - "no": "Innstillinger for turoppsummering" + "titleFormatted": { + "en": "Amp limit !{{is|is not}} at least [[amperage]] A", + "no": "Amperebegrensning !{{er|er ikke}} minst [[amperage]] A", + "nl": "Amperebegrenzing !{{is|is niet}} minimaal [[amperage]] A" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" }, - "children": [ - { - "id": "tripSummaryEnabled", - "type": "checkbox", - "value": true, - "label": { - "en": "Enable trip summary", - "no": "Aktiver turoppsummering" - }, - "hint": { - "en": "Enable or disable the trip summary. (Optional) NOTE: By enabling you accept that your trip data will be stored anonymously in the cloud. You can disable this at any time. By deleting the device, you will also permanently delete all your trip data. App restart is required after changing this setting.", - "no": "Aktiver eller deaktiver turoppsummeringen. (Valgfritt) MERK: Ved å aktivere godtar du at turoppsummeringen lagres anonymt i skyen. Du kan deaktivere dette når som helst. Ved å slette enheten vil du også slette all turoppsummeringsdata permanent. App restart er nødvendig etter endring av denne innstillingen." - } - }, - { - "id": "tripSummaryStyle", - "type": "dropdown", - "value": "light", - "label": { - "en": "Trip summary style", - "no": "Turoppsummeringens stil" - }, - "hint": { - "en": "This changes the style of the trip summary. (Optional)", - "no": "Dette endrer stilen til turoppsummeringen. (Valgfritt)" - }, - "values": [ - { - "id": "light", - "label": { - "en": "Light", - "no": "Lys" - } - }, - { - "id": "dark", - "label": { - "en": "Dark", - "no": "Mørk" - } - } - ] - }, - { - "id": "tripInfoStyle", - "type": "dropdown", - "value": "light", + { + "type": "number", + "name": "amperage", + "min": 6, + "max": 32, + "step": 1, + "placeholder": { + "en": "A", + "no": "A", + "nl": "A" + } + } + ] + } + ], + "actions": [ + { + "id": "charge_start", + "title": { + "en": "Start charging (override timer)", + "no": "Start lading (overstyr tidtaker)", + "nl": "Laden starten (negeer timer)" + }, + "titleFormatted": { + "en": "Start charging (override timer)", + "no": "Start lading (overstyr tidtaker)", + "nl": "Laden starten (negeer timer)" + }, + "hint": { + "en": "Overrides any scheduled charging timer and starts drawing power now. Requires the charger to be connected and 'Allow remote commands' to be enabled in device settings.", + "no": "Overstyr planlagt lading og start nå. Laderen må være tilkoblet og 'Tillat fjernkommandoer' må være på i enhetsinnstillingene.", + "nl": "Overschrijft een geplande laadtimer en begint direct met laden. Lader moet verbonden zijn en 'Afstandscommando's toestaan' moet aan staan in de device-instellingen." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "charge_stop", + "title": { + "en": "Stop charging (override timer)", + "no": "Stopp lading (overstyr tidtaker)", + "nl": "Laden stoppen (negeer timer)" + }, + "titleFormatted": { + "en": "Stop charging (override timer)", + "no": "Stopp lading (overstyr tidtaker)", + "nl": "Laden stoppen (negeer timer)" + }, + "hint": { + "en": "Cancels the charge-now override and lets the scheduled timer take over again.", + "no": "Avbryt lad-nå-overstyringen og overlat kontrollen til planlagt tidtaker.", + "nl": "Heft de lad-nu-override op; de geplande timer neemt het weer over." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "set_target_soc", + "title": { + "en": "Set charge limit", + "no": "Sett ladegrense", + "nl": "Laadlimiet instellen" + }, + "titleFormatted": { + "en": "Set charge limit to [[level]]%", + "no": "Sett ladegrense til [[level]]%", + "nl": "Laadlimiet instellen op [[level]]%" + }, + "hint": { + "en": "Target state-of-charge where the car stops charging (50–100%).", + "no": "Ladegrense der bilen stopper ladingen (50–100%).", + "nl": "SoC-doel waarop de auto stopt met laden (50–100%)." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + }, + { + "type": "number", + "name": "level", + "min": 50, + "max": 100, + "step": 1, + "placeholder": { + "en": "%", + "no": "%", + "nl": "%" + } + } + ] + }, + { + "id": "lock_car", + "title": { + "en": "Lock car", + "no": "Lås bil", + "nl": "Auto vergrendelen" + }, + "titleFormatted": { + "en": "Lock car", + "no": "Lås bil", + "nl": "Auto vergrendelen" + }, + "hint": { + "en": "Activate the central lock.", + "no": "Aktiver sentrallås.", + "nl": "Activeert de centrale vergrendeling." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "unlock_car", + "title": { + "en": "Unlock car", + "no": "Lås opp bil", + "nl": "Auto ontgrendelen" + }, + "titleFormatted": { + "en": "Unlock car", + "no": "Lås opp bil", + "nl": "Auto ontgrendelen" + }, + "hint": { + "en": "Unlock all doors.", + "no": "Lås opp alle dører.", + "nl": "Ontgrendelt alle deuren." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "unlock_trunk_action", + "title": { + "en": "Unlock trunk", + "no": "Lås opp bagasjerom", + "nl": "Kofferbak ontgrendelen" + }, + "titleFormatted": { + "en": "Unlock trunk", + "no": "Lås opp bagasjerom", + "nl": "Kofferbak ontgrendelen" + }, + "hint": { + "en": "Unlock only the trunk; the cabin stays locked.", + "no": "Lås opp kun bagasjerommet; kupeen forblir låst.", + "nl": "Alleen de kofferbak ontgrendelen, interieur blijft vergrendeld." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "honk_flash", + "title": { + "en": "Find my car", + "no": "Finn bilen", + "nl": "Vind mijn auto" + }, + "titleFormatted": { + "en": "Find my car — [[action]]", + "no": "Finn bilen — [[action]]", + "nl": "Vind mijn auto — [[action]]" + }, + "hint": { + "en": "Find-my-car: flash the lights, honk the horn, or both.", + "no": "Finn bilen: blink med lys, tut i hornet, eller begge.", + "nl": "Vind-mijn-auto: knipper met lichten, toeter, of beide." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + }, + { + "type": "dropdown", + "name": "action", + "values": [ + { + "id": "flash", + "label": { + "en": "Flash lights only", + "no": "Kun blink", + "nl": "Alleen knipperen" + } + }, + { + "id": "honk", + "label": { + "en": "Honk only", + "no": "Kun tut", + "nl": "Alleen toeteren" + } + }, + { + "id": "both", + "label": { + "en": "Honk and flash", + "no": "Tut og blink", + "nl": "Toeteren en knipperen" + } + } + ] + } + ] + }, + { + "id": "climate_start_simple", + "title": { + "en": "Start climate (defaults)", + "no": "Start klima (standard)", + "nl": "Climate starten (standaard)" + }, + "titleFormatted": { + "en": "Start climate (defaults)", + "no": "Start klima (standard)", + "nl": "Climate starten (standaard)" + }, + "hint": { + "en": "Start parking climatization using the temperature and seat/steering defaults from device settings. Same behaviour as tapping the climate button in the Polestar app.", + "no": "Start klima med standard temperatur og sete/ratt-instillinger fra enhetsinnstillinger.", + "nl": "Start parkeer-climatisering met de standaard temperatuur en stoel/stuurwiel-instellingen uit device-settings." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "climate_start", + "title": { + "en": "Start climate", + "no": "Start klima", + "nl": "Climate starten" + }, + "titleFormatted": { + "en": "Start climate to [[temp]]°C, seats FL [[seat_fl]] FR [[seat_fr]] RL [[seat_rl]] RR [[seat_rr]], steering [[wheel]]", + "no": "Start klima til [[temp]]°C, seter FV [[seat_fl]] FH [[seat_fr]] BV [[seat_rl]] BH [[seat_rr]], ratt [[wheel]]", + "nl": "Climate starten op [[temp]]°C, zittingen LV [[seat_fl]] RV [[seat_fr]] LA [[seat_rl]] RA [[seat_rr]], stuur [[wheel]]" + }, + "hint": { + "en": "Start parking climatization with explicit temperature, per-seat heating and steering-wheel heating. Bypasses the device-settings defaults.", + "no": "Start klima med eksplisitt temperatur, setevarme og rattvarme. Overstyrer standard fra enhetsinnstillinger.", + "nl": "Start climatiseren met expliciete temperatuur, stoelverwarming per plek en stuurverwarming. Overschrijft defaults uit device-settings." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + }, + { + "type": "number", + "name": "temp", + "min": 16, + "max": 30, + "step": 0.5, + "placeholder": { + "en": "°C", + "no": "°C", + "nl": "°C" + } + }, + { + "type": "dropdown", + "name": "seat_fl", + "values": [ + { + "id": "1", + "label": { + "en": "Seat FL off", + "no": "Sete FV av", + "nl": "Stoel LV uit" + } + }, + { + "id": "2", + "label": { + "en": "Seat FL L1", + "no": "Sete FV N1", + "nl": "Stoel LV N1" + } + }, + { + "id": "3", + "label": { + "en": "Seat FL L2", + "no": "Sete FV N2", + "nl": "Stoel LV N2" + } + }, + { + "id": "4", + "label": { + "en": "Seat FL L3", + "no": "Sete FV N3", + "nl": "Stoel LV N3" + } + } + ] + }, + { + "type": "dropdown", + "name": "seat_fr", + "values": [ + { + "id": "1", + "label": { + "en": "Seat FR off", + "no": "Sete FH av", + "nl": "Stoel RV uit" + } + }, + { + "id": "2", + "label": { + "en": "Seat FR L1", + "no": "Sete FH N1", + "nl": "Stoel RV N1" + } + }, + { + "id": "3", + "label": { + "en": "Seat FR L2", + "no": "Sete FH N2", + "nl": "Stoel RV N2" + } + }, + { + "id": "4", + "label": { + "en": "Seat FR L3", + "no": "Sete FH N3", + "nl": "Stoel RV N3" + } + } + ] + }, + { + "type": "dropdown", + "name": "seat_rl", + "values": [ + { + "id": "1", + "label": { + "en": "Seat RL off", + "no": "Sete BV av", + "nl": "Stoel LA uit" + } + }, + { + "id": "2", + "label": { + "en": "Seat RL L1", + "no": "Sete BV N1", + "nl": "Stoel LA N1" + } + }, + { + "id": "3", + "label": { + "en": "Seat RL L2", + "no": "Sete BV N2", + "nl": "Stoel LA N2" + } + }, + { + "id": "4", + "label": { + "en": "Seat RL L3", + "no": "Sete BV N3", + "nl": "Stoel LA N3" + } + } + ] + }, + { + "type": "dropdown", + "name": "seat_rr", + "values": [ + { + "id": "1", + "label": { + "en": "Seat RR off", + "no": "Sete BH av", + "nl": "Stoel RA uit" + } + }, + { + "id": "2", + "label": { + "en": "Seat RR L1", + "no": "Sete BH N1", + "nl": "Stoel RA N1" + } + }, + { + "id": "3", + "label": { + "en": "Seat RR L2", + "no": "Sete BH N2", + "nl": "Stoel RA N2" + } + }, + { + "id": "4", + "label": { + "en": "Seat RR L3", + "no": "Sete BH N3", + "nl": "Stoel RA N3" + } + } + ] + }, + { + "type": "dropdown", + "name": "wheel", + "values": [ + { + "id": "1", + "label": { + "en": "Steering off", + "no": "Ratt av", + "nl": "Stuur uit" + } + }, + { + "id": "2", + "label": { + "en": "Steering L1", + "no": "Ratt N1", + "nl": "Stuur N1" + } + }, + { + "id": "3", + "label": { + "en": "Steering L2", + "no": "Ratt N2", + "nl": "Stuur N2" + } + }, + { + "id": "4", + "label": { + "en": "Steering L3", + "no": "Ratt N3", + "nl": "Stuur N3" + } + } + ] + } + ] + }, + { + "id": "climate_stop", + "title": { + "en": "Stop climate", + "no": "Stopp klima", + "nl": "Climate stoppen" + }, + "titleFormatted": { + "en": "Stop climate", + "no": "Stopp klima", + "nl": "Climate stoppen" + }, + "hint": { + "en": "Stop any running parking climatization session.", + "no": "Stopp pågående klimasesjon.", + "nl": "Stop een lopende climate-sessie." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "windows_open", + "title": { + "en": "Open all windows", + "no": "Åpne alle vinduer", + "nl": "Alle ramen openen" + }, + "titleFormatted": { + "en": "Open all windows", + "no": "Åpne alle vinduer", + "nl": "Alle ramen openen" + }, + "hint": { + "en": "Open all four side windows — e.g. to vent a hot cabin.", + "no": "Åpne alle fire sidevinduer.", + "nl": "Open alle vier zijramen — bv. om een hete cabine te luchten." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "windows_close", + "title": { + "en": "Close all windows", + "no": "Lukk alle vinduer", + "nl": "Alle ramen sluiten" + }, + "titleFormatted": { + "en": "Close all windows", + "no": "Lukk alle vinduer", + "nl": "Alle ramen sluiten" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "get_location", + "title": { + "en": "Get location", + "no": "Hent plassering", + "nl": "Locatie ophalen" + }, + "titleFormatted": { + "en": "Get location", + "no": "Hent plassering", + "nl": "Locatie ophalen" + }, + "hint": { + "en": "Fetches the last known position and exposes latitude, longitude, and a formatted location string as tokens for subsequent flow cards (e.g. push notifications with a map link).", + "no": "Henter siste kjente posisjon og eksponerer breddegrad, lengdegrad og en formatert tekststreng som tokens.", + "nl": "Haalt de laatst bekende positie op en levert breedtegraad, lengtegraad en een geformatteerde tekststring als tokens voor volgende flow-cards (bv. push met kaart-link)." + }, + "tokens": [ + { + "name": "latitude", + "type": "number", + "title": { + "en": "Latitude", + "no": "Breddegrad", + "nl": "Breedtegraad" + } + }, + { + "name": "longitude", + "type": "number", + "title": { + "en": "Longitude", + "no": "Lengdegrad", + "nl": "Lengtegraad" + } + }, + { + "name": "location", + "type": "string", + "title": { + "en": "Location", + "no": "Plassering", + "nl": "Locatie" + } + } + ], + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + } + ] + }, + { + "id": "set_amp_limit", + "title": { + "en": "Set charging amperage limit", + "no": "Sett maks ampere ved lading", + "nl": "Ampère-limiet instellen bij laden" + }, + "titleFormatted": { + "en": "Set amperage limit to [[amperage]] A", + "no": "Sett ampere-grense til [[amperage]] A", + "nl": "Ampère-limiet instellen op [[amperage]] A" + }, + "hint": { + "en": "Caps the AC charging current (6–32 A). Note: Polestar 4 currently reports this feature as unsupported.", + "no": "Maks vekselstrømsladestrøm (6–32 A). Merk: Polestar 4 rapporterer dette som ikke støttet.", + "nl": "Maximum AC-laadstroom (6–32 A). Let op: Polestar 4 meldt dat deze functie niet wordt ondersteund." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=vehicle" + }, + { + "type": "number", + "name": "amperage", + "min": 6, + "max": 32, + "step": 1, + "placeholder": { + "en": "A", + "no": "A", + "nl": "A" + } + } + ] + } + ] + }, + "drivers": [ + { + "name": { + "en": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)", + "no": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)", + "nl": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)" + }, + "class": "sensor", + "deprecated": true, + "capabilities": [ + "measure_battery", + "measure_polestarIgnitionState", + "measure_polestarGear", + "measure_polestarSpeed", + "measure_polestarAlt", + "measure_polestarBattery", + "measure_polestarRange", + "measure_polestarBatteryLevel", + "measure_polestarPower", + "measure_polestarConnected", + "measure_polestarLocation", + "measure_polestarTemp", + "measure_polestarUpdated" + ], + "measure_power": { + "approximated": true + }, + "platforms": [ + "local" + ], + "connectivity": [ + "cloud" + ], + "images": { + "small": "/drivers/polestar-2-csv/assets/images/small.png", + "large": "/drivers/polestar-2-csv/assets/images/large.png" + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "step2" + } + }, + { + "id": "step2", + "navigation": { + "prev": "start" + } + }, + { + "id": "getPolestar" + }, + { + "id": "list_devices", + "template": "list_devices", + "options": { + "singular": true + }, + "navigation": { + "next": "add_device" + } + }, + { + "id": "add_device", + "template": "add_devices" + } + ], + "energy": { + "batteries": [ + "INTERNAL" + ] + }, + "id": "polestar-2-csv", + "settings": [ + { + "type": "group", + "label": { + "en": "General settings", + "no": "Generelle innstillinger", + "nl": "Algemene instellingen" + }, + "children": [ + { + "id": "endTripThreshold", + "type": "number", + "value": 10, + "label": { + "en": "End trip threshold", + "no": "Terskel for endt tur", + "nl": "Einde rit drempel tijd" + }, + "hint": { + "en": "The time in minutes after which a trip should be considered ended. (Optional)", + "no": "Terskel i minutter for når en tur skal anses som avsluttet. (Valgfritt)", + "nl": "De tijd in minuten waarna de rit wordt beschouwd als geindigd. (Optioneel)" + } + } + ] + }, + { + "type": "group", + "label": { + "en": "Trip summary settings", + "no": "Innstillinger for turoppsummering", + "nl": "Rit samenvatting instellingen" + }, + "children": [ + { + "id": "tripSummaryEnabled", + "type": "checkbox", + "value": true, + "label": { + "en": "Enable trip summary", + "no": "Aktiver turoppsummering", + "nl": "Gebruik rit samenvatting" + }, + "hint": { + "en": "Enable or disable the trip summary. (Optional) NOTE: By enabling you accept that your trip data will be stored anonymously in the cloud. You can disable this at any time. By deleting the device, you will also permanently delete all your trip data. App restart is required after changing this setting.", + "no": "Aktiver eller deaktiver turoppsummeringen. (Valgfritt) MERK: Ved å aktivere godtar du at turoppsummeringen lagres anonymt i skyen. Du kan deaktivere dette når som helst. Ved å slette enheten vil du også slette all turoppsummeringsdata permanent. App restart er nødvendig etter endring av denne innstillingen.", + "nl": "Aan of uit zetten van de rit samenvatting. (Optioneel) OPMERKING: Door de rit samenvatting te activeren accepteert u dat de rit informatie annoniem wordt opgeslagen in de cloud. U kunt dit ten alle tijden weer uitzetten. Als u dit device weer verwijdert wordt ook de cloud data permanent verwijdert. De App moet opnieuw worden opgestart als u deze instelling aanpast." + } + }, + { + "id": "tripSummaryStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip summary style", + "no": "Turoppsummeringens stil", + "nl": "Rit samenvatting uiterlijk" + }, + "hint": { + "en": "This changes the style of the trip summary. (Optional)", + "no": "Dette endrer stilen til turoppsummeringen. (Valgfritt)", + "nl": "Dit past het uiterlijk van de rit samenvatting aan. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } + }, + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } + } + ] + }, + { + "id": "tripInfoStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip info style", + "no": "Turinfoens stil", + "nl": "Rit samenvatting stijl" + }, + "hint": { + "en": "This changes the style of the trip info. (Optional)", + "no": "Dette endrer stilen til turinfoen. (Valgfritt)", + "nl": "Dit verandert de stijl van de rit samenvatting. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } + }, + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } + } + ] + }, + { + "id": "tripScoreStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip score style", + "no": "Kjørescorens stil", + "nl": "Rit score stijl" + }, + "hint": { + "en": "This changes the style of the trip score. (Optional)", + "no": "Dette endrer stilen til kjørescoren. (Valgfritt)", + "nl": "Dit verandert de stijl van de rit score. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } + }, + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } + } + ] + }, + { + "id": "mapImageType", + "type": "dropdown", + "value": "mapboxOutdoors", + "label": { + "en": "Map image type", + "no": "Kart-bildetype", + "nl": "Kaartweergave stijl" + }, + "hint": { + "en": "This changes the type of the map that is shown in the trip summary. (Optional)", + "no": "Dette endrer hvilket type kart som vises i turoppsummeringen. (Valgfritt)", + "nl": "Dit verandert de stijl van de kaart die bij de rit samenvatting wordt weergegeven. (Optioneel)" + }, + "values": [ + { + "id": "mapboxLight", + "label": { + "en": "Mapbox Light", + "no": "Mapbox Light", + "nl": "Mapbox Ligt" + } + }, + { + "id": "mapboxDark", + "label": { + "en": "Mapbox Dark", + "no": "Mapbox Dark", + "nl": "Mapbox Donker" + } + }, + { + "id": "mapboxStreets", + "label": { + "en": "Mapbox Streets", + "no": "Mapbox Streets", + "nl": "Mapbox Straten" + } + }, + { + "id": "mapboxOutdoors", + "label": { + "en": "Mapbox Outdoors", + "no": "Mapbox Outdoors", + "nl": "Mapbox Buitenleven" + } + }, + { + "id": "mapboxSatellite", + "label": { + "en": "Mapbox Satellite", + "no": "Mapbox Satellite", + "nl": "Mapbox Sateliet" + } + }, + { + "id": "mapboxSatelliteStreets", + "label": { + "en": "Mapbox Satellite Streets", + "no": "Mapbox Satellite Streets", + "nl": "Mapbox Sateliet Straten" + } + } + ] + } + ] + }, + { + "type": "group", + "label": { + "en": "Webhook Settings", + "no": "Webhook innstillinger", + "nl": "Webhook instellingen" + }, + "children": [ + { + "id": "polestar_webhook", + "type": "text", + "hint": { + "en": "Do not change unless instructed by developer, or if you changed the webhook. This setting has no function, and is only for information.", + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler, eller hvis du har endret webhooken. Denne innstillingen har ingen funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen, of tenzij je de webhook hebt aangepast. Deze setting heeft geen functie, en is er ter informatie" + }, "label": { - "en": "Trip info style", - "no": "Turinfoens stil" + "en": "Webhook URL", + "no": "Webhook URL", + "nl": "Webhook URL" + } + }, + { + "id": "webhook_url_short", + "type": "text", + "hint": { + "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen. Deze setting heeft geen functie, en is er ter informatie" + }, + "label": { + "en": "Webhook URL (short)", + "no": "Webhook URL (kort)", + "nl": "Webhook URL (kort)" + } + }, + { + "id": "webhook_slug", + "type": "label", + "hint": { + "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen. Deze setting heeft geen functie, en is er ter informatie" }, + "label": { + "en": "Webhook slug", + "no": "Webhook slug", + "nl": "Webhook slug" + } + }, + { + "id": "webhook_id", + "type": "label", "hint": { - "en": "This changes the style of the trip info. (Optional)", - "no": "Dette endrer stilen til turinfoen. (Valgfritt)" + "en": "Do not change unless instructed by developer.", + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen." + }, + "label": { + "en": "Webhook ID", + "no": "Webhook ID", + "nl": "Webhook ID" + } + }, + { + "id": "webhook_secret", + "type": "label", + "hint": { + "en": "Do not change unless instructed by developer.", + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen." + }, + "label": { + "en": "Webhook secret", + "no": "Webhook secret", + "nl": "Webhook secret" + } + } + ] + } + ] + }, + { + "name": { + "en": "My Polestar", + "no": "Min Polestar", + "nl": "Mijn Polestar" + }, + "class": "car", + "capabilities": [ + "measure_battery", + "ev_charging_state", + "measure_power", + "measure_current", + "measure_voltage", + "meter_power", + "measure_polestarDrivingKwh", + "measure_polestarSessionKwh", + "measure_polestarChargingType", + "target_polestarChargeLimit", + "target_polestarAmpLimit", + "button.charge_start", + "button.charge_stop", + "button.honk_flash", + "button.unlock_trunk", + "button.windows_open", + "button.windows_close", + "locked", + "onoff.climate", + "target_temperature", + "measure_temperature", + "measure_polestarClimateRemaining", + "measure_polestarLocation", + "alarm_polestarOtaAvailable", + "measure_polestarOtaState", + "measure_polestarOtaVersion", + "alarm_contact.door_front_left", + "alarm_contact.door_front_right", + "alarm_contact.door_rear_left", + "alarm_contact.door_rear_right", + "alarm_contact.window_any", + "alarm_contact.tailgate", + "alarm_contact.hood", + "alarm_contact.sunroof", + "alarm_contact.tank_lid", + "alarm_polestarTyrePressure", + "measure_pressure.front_left", + "measure_pressure.front_right", + "measure_pressure.rear_left", + "measure_pressure.rear_right" + ], + "energy": { + "electricCar": true + }, + "capabilitiesOptions": { + "button.charge_start": { + "title": { + "en": "Start charging", + "no": "Start lading", + "nl": "Laden starten" + }, + "desc": { + "en": "Override the charge timer and start charging now.", + "no": "Overstyr ladetidtakeren og start lading nå.", + "nl": "Negeer de laadtimer en begin direct met laden." + } + }, + "button.charge_stop": { + "title": { + "en": "Stop charging", + "no": "Stopp lading", + "nl": "Laden stoppen" + }, + "desc": { + "en": "Cancel the charge-now override.", + "no": "Avbryt lad-nå-overstyringen.", + "nl": "Heft de lad-nu-override op." + } + }, + "button.honk_flash": { + "title": { + "en": "Honk and flash", + "no": "Tut og blink", + "nl": "Toeter en knipper" + }, + "desc": { + "en": "Find the car — honk horn and flash lights.", + "no": "Finn bilen — tut og blink med lysene.", + "nl": "Vind de auto — toeteren en lichten knipperen." + } + }, + "button.unlock_trunk": { + "title": { + "en": "Unlock trunk", + "no": "Lås opp bagasjerom", + "nl": "Kofferbak ontgrendelen" + }, + "desc": { + "en": "Unlock only the trunk, leaving the cabin locked.", + "no": "Lås opp kun bagasjerommet.", + "nl": "Ontgrendel alleen de kofferbak; het interieur blijft vergrendeld." + } + }, + "button.windows_open": { + "title": { + "en": "Open windows", + "no": "Åpne vinduer", + "nl": "Ramen openen" + }, + "desc": { + "en": "Open all four side windows.", + "no": "Åpne alle fire sidevinduer.", + "nl": "Open alle vier zijramen." + } + }, + "button.windows_close": { + "title": { + "en": "Close windows", + "no": "Lukk vinduer", + "nl": "Ramen sluiten" + }, + "desc": { + "en": "Close all side windows.", + "no": "Lukk alle sidevinduer.", + "nl": "Sluit alle zijramen." + } + }, + "measure_pressure.front_left": { + "title": { + "en": "Tyre pressure FL", + "no": "Dekktrykk FV", + "nl": "Bandenspanning LV" + }, + "units": { + "en": "kPa" + }, + "decimals": 0, + "insights": true + }, + "measure_pressure.front_right": { + "title": { + "en": "Tyre pressure FR", + "no": "Dekktrykk FH", + "nl": "Bandenspanning RV" + }, + "units": { + "en": "kPa" + }, + "decimals": 0, + "insights": true + }, + "measure_pressure.rear_left": { + "title": { + "en": "Tyre pressure RL", + "no": "Dekktrykk BV", + "nl": "Bandenspanning LA" + }, + "units": { + "en": "kPa" + }, + "decimals": 0, + "insights": true + }, + "measure_pressure.rear_right": { + "title": { + "en": "Tyre pressure RR", + "no": "Dekktrykk BH", + "nl": "Bandenspanning RA" + }, + "units": { + "en": "kPa" + }, + "decimals": 0, + "insights": true + }, + "locked": { + "title": { + "en": "Locked", + "no": "Låst", + "nl": "Vergrendeld" + }, + "desc": { + "en": "Central-lock state. Toggle to lock/unlock the car.", + "no": "Status for sentrallås. Trykk for å låse / låse opp bilen.", + "nl": "Centrale-vergrendelingsstatus. Schakel om de auto te vergrendelen of ontgrendelen." + } + }, + "onoff.climate": { + "title": { + "en": "Climate", + "no": "Klima", + "nl": "Klimaat" + }, + "desc": { + "en": "Start or stop parking climatization. Uses the heating settings from device settings.", + "no": "Start eller stopp klimaanlegg. Bruker oppvarmingsinnstillinger fra enhetsinnstillinger.", + "nl": "Start of stop de parkeer-klimaatregeling. Gebruikt de verwarmingsinstellingen uit de device-settings." + } + }, + "target_temperature": { + "min": 16, + "max": 30, + "step": 0.5, + "decimals": 1, + "title": { + "en": "Climate target", + "no": "Klima-mål", + "nl": "Klimaatdoel" + }, + "desc": { + "en": "Desired climatization temperature. Used as default when starting climate from the toggle.", + "no": "Ønsket klimatemperatur. Brukes som standard ved start via klima-bryteren.", + "nl": "Gewenste klimaattemperatuur. Wordt als default gebruikt bij het aanzetten via de climate-toggle." + } + }, + "measure_temperature": { + "title": { + "en": "Interior temperature", + "no": "Innetemperatur", + "nl": "Binnentemperatuur" + } + }, + "alarm_contact.door_front_left": { + "title": { + "en": "Door FL open", + "no": "Dør FV åpen", + "nl": "Deur LV open" + } + }, + "alarm_contact.door_front_right": { + "title": { + "en": "Door FR open", + "no": "Dør FH åpen", + "nl": "Deur RV open" + } + }, + "alarm_contact.door_rear_left": { + "title": { + "en": "Door RL open", + "no": "Dør BV åpen", + "nl": "Deur LA open" + } + }, + "alarm_contact.door_rear_right": { + "title": { + "en": "Door RR open", + "no": "Dør BH åpen", + "nl": "Deur RA open" + } + }, + "alarm_contact.window_any": { + "title": { + "en": "Window open", + "no": "Vindu åpent", + "nl": "Raam open" + } + }, + "alarm_contact.tailgate": { + "title": { + "en": "Tailgate open", + "no": "Bakluke åpen", + "nl": "Kofferbak open" + } + }, + "alarm_contact.hood": { + "title": { + "en": "Hood open", + "no": "Panser åpent", + "nl": "Motorkap open" + } + }, + "alarm_contact.sunroof": { + "title": { + "en": "Sunroof open", + "no": "Takluke åpen", + "nl": "Schuifdak open" + } + }, + "alarm_contact.tank_lid": { + "title": { + "en": "Charge port open", + "no": "Ladeluke åpen", + "nl": "Laadklep open" + } + } + }, + "platforms": [ + "local" + ], + "connectivity": [ + "cloud" + ], + "images": { + "small": "/drivers/vehicle/assets/images/small.png", + "large": "/drivers/vehicle/assets/images/large.png", + "xlarge": "/drivers/vehicle/assets/images/xlarge.png" + }, + "repair": [ + { + "id": "login" + } + ], + "pair": [ + { + "id": "login" + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "vehicle", + "settings": [ + { + "id": "writes_enabled", + "type": "checkbox", + "label": { + "en": "Allow remote commands", + "no": "Tillat fjernkommandoer", + "nl": "Afstandscommando's toestaan" + }, + "value": true, + "hint": { + "en": "Master switch for write flows: start/stop charging, charge limit, amp limit, and (later) locks, windows, climate. Disable to make every write flow fail instantly.", + "no": "Hovedbryter for skriveflyter. Slå av for å få alle skriveflyter til å feile umiddelbart.", + "nl": "Hoofdschakelaar voor schrijfflows. Zet uit om alle schrijfflows direct te laten falen." + } + }, + { + "id": "target_soc_setting_type", + "type": "dropdown", + "label": { + "en": "Charge limit slot", + "no": "Ladegrense-slot", + "nl": "Laadlimiet-slot" + }, + "value": "1", + "hint": { + "en": "Which charge-limit slot the car should use when Homey writes a new value. Some Polestar models silently ignore writes to the wrong slot. Try 'Custom' if changes don't appear in the Polestar app.", + "no": "Hvilket ladegrenseslot bilen bruker når Homey skriver en ny verdi. Prøv 'Egendefinert' om endringer ikke vises i Polestar-appen.", + "nl": "Welk slot voor de laadlimiet de auto gebruikt als Homey een nieuwe waarde schrijft. Sommige Polestar-modellen negeren writes naar het verkeerde slot stil. Probeer 'Aangepast' als wijzigingen niet in de Polestar-app verschijnen." + }, + "values": [ + { + "id": "1", + "label": { + "en": "Daily (default)", + "no": "Daglig (standard)", + "nl": "Dagelijks (standaard)" + } + }, + { + "id": "2", + "label": { + "en": "Long trip", + "no": "Lang tur", + "nl": "Lange rit" + } + }, + { + "id": "3", + "label": { + "en": "Custom", + "no": "Egendefinert", + "nl": "Aangepast" + } + }, + { + "id": "0", + "label": { + "en": "Unspecified", + "no": "Uspesifisert", + "nl": "Niet gespecificeerd" + } + } + ] + }, + { + "id": "windows_supported", + "type": "checkbox", + "label": { + "en": "Windows remote control", + "no": "Fjernstyring av vinduer", + "nl": "Ramen op afstand bedienen" + }, + "value": true, + "hint": { + "en": "Leave checked unless your Polestar model responds with 'not supported' when trying Open/Close windows. Uncheck to hide the two window buttons from the device page. Auto-disabled if the backend returns UNIMPLEMENTED.", + "no": "La stå påskrudd med mindre bilen svarer 'ikke støttet' når du prøver Åpne/Lukk vinduer. Fjern haken for å skjule vinduskontrollene. Deaktiveres automatisk ved UNIMPLEMENTED.", + "nl": "Laat aangevinkt tenzij je Polestar 'niet ondersteund' meldt bij Openen/Sluiten van ramen. Uitvinken verbergt de raam-knoppen van de device-pagina. Automatisch uitgeschakeld als de backend UNIMPLEMENTED teruggeeft." + } + }, + { + "type": "group", + "label": { + "en": "Climate defaults", + "no": "Klima-standarder", + "nl": "Climate standaardinstellingen" + }, + "children": [ + { + "id": "climate_default_temp", + "type": "number", + "label": { + "en": "Default temperature", + "no": "Standard temperatur", + "nl": "Standaard temperatuur" + }, + "value": 21, + "min": 16, + "max": 30, + "step": 0.5, + "units": { + "en": "°C" + }, + "hint": { + "en": "Used when climate is started via the toggle, button, or onoff capability. The separate 'Climate target' tile overrides this for the next start.", + "no": "Brukes når klima startes via bryter, knapp eller onoff. 'Klima-mål'-flisen overstyrer dette ved neste start.", + "nl": "Gebruikt wanneer climate via de toggle, knop of onoff-capability wordt gestart. De 'Klimaatdoel'-tile overschrijft dit voor de eerstvolgende start." + } + }, + { + "id": "climate_seat_front_left", + "type": "dropdown", + "label": { + "en": "Front left seat heating", + "no": "Setevarme forran venstre", + "nl": "Zitverwarming linksvoor" + }, + "value": "1", + "values": [ + { + "id": "1", + "label": { + "en": "Off" + } + }, + { + "id": "2", + "label": { + "en": "Level 1" + } + }, + { + "id": "3", + "label": { + "en": "Level 2" + } + }, + { + "id": "4", + "label": { + "en": "Level 3" + } + } + ] + }, + { + "id": "climate_seat_front_right", + "type": "dropdown", + "label": { + "en": "Front right seat heating", + "no": "Setevarme forran høyre", + "nl": "Zitverwarming rechtsvoor" + }, + "value": "1", + "values": [ + { + "id": "1", + "label": { + "en": "Off" + } + }, + { + "id": "2", + "label": { + "en": "Level 1" + } + }, + { + "id": "3", + "label": { + "en": "Level 2" + } + }, + { + "id": "4", + "label": { + "en": "Level 3" + } + } + ] + }, + { + "id": "climate_seat_rear_left", + "type": "dropdown", + "label": { + "en": "Rear left seat heating", + "no": "Setevarme bak venstre", + "nl": "Zitverwarming linksachter" }, + "value": "1", "values": [ { - "id": "light", + "id": "1", + "label": { + "en": "Off" + } + }, + { + "id": "2", + "label": { + "en": "Level 1" + } + }, + { + "id": "3", "label": { - "en": "Light", - "no": "Lys" + "en": "Level 2" } }, { - "id": "dark", + "id": "4", "label": { - "en": "Dark", - "no": "Mørk" + "en": "Level 3" } } ] }, { - "id": "tripScoreStyle", + "id": "climate_seat_rear_right", "type": "dropdown", - "value": "light", "label": { - "en": "Trip score style", - "no": "Kjørescorens stil" - }, - "hint": { - "en": "This changes the style of the trip score. (Optional)", - "no": "Dette endrer stilen til kjørescoren. (Valgfritt)" + "en": "Rear right seat heating", + "no": "Setevarme bak høyre", + "nl": "Zitverwarming rechtsachter" }, + "value": "1", "values": [ { - "id": "light", + "id": "1", "label": { - "en": "Light", - "no": "Lys" + "en": "Off" } }, { - "id": "dark", + "id": "2", "label": { - "en": "Dark", - "no": "Mørk" + "en": "Level 1" + } + }, + { + "id": "3", + "label": { + "en": "Level 2" + } + }, + { + "id": "4", + "label": { + "en": "Level 3" } } ] }, { - "id": "mapImageType", + "id": "climate_steering_wheel", "type": "dropdown", - "value": "mapboxOutdoors", "label": { - "en": "Map image type", - "no": "Kart-bildetype" - }, - "hint": { - "en": "This changes the type of the map that is shown in the trip summary. (Optional)", - "no": "Dette endrer hvilket type kart som vises i turoppsummeringen. (Valgfritt)" + "en": "Steering wheel heating", + "no": "Rattvarme", + "nl": "Stuurwielverwarming" }, + "value": "1", "values": [ { - "id": "mapboxLight", - "label": { - "en": "Mapbox Light", - "no": "Mapbox Light" - } - }, - { - "id": "mapboxDark", - "label": { - "en": "Mapbox Dark", - "no": "Mapbox Dark" - } - }, - { - "id": "mapboxStreets", + "id": "1", "label": { - "en": "Mapbox Streets", - "no": "Mapbox Streets" + "en": "Off" } }, { - "id": "mapboxOutdoors", + "id": "2", "label": { - "en": "Mapbox Outdoors", - "no": "Mapbox Outdoors" + "en": "Level 1" } }, { - "id": "mapboxSatellite", + "id": "3", "label": { - "en": "Mapbox Satellite", - "no": "Mapbox Satellite" + "en": "Level 2" } }, { - "id": "mapboxSatelliteStreets", + "id": "4", "label": { - "en": "Mapbox Satellite Streets", - "no": "Mapbox Satellite Streets" + "en": "Level 3" } } ] } ] - }, - { - "type": "group", - "label": { - "en": "Webhook Settings", - "no": "Webhook innstillinger" - }, - "children": [ - { - "id": "polestar_webhook", - "type": "text", - "hint": { - "en": "Do not change unless instructed by developer, or if you changed the webhook. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler, eller hvis du har endret webhooken. Denne innstillingen har ingen funksjon, og er kun til informasjon." - }, - "label": { - "en": "Webhook URL", - "no": "Webhook URL" - } - }, - { - "id": "webhook_url_short", - "type": "text", - "hint": { - "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon." - }, - "label": { - "en": "Webhook URL (short)", - "no": "Webhook URL (kort)" - } - }, - { - "id": "webhook_slug", - "type": "label", - "hint": { - "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon." - }, - "label": { - "en": "Webhook slug", - "no": "Webhook slug" - } - }, - { - "id": "webhook_id", - "type": "label", - "hint": { - "en": "Do not change unless instructed by developer.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler." - }, - "label": { - "en": "Webhook ID", - "no": "Webhook ID" - } - }, - { - "id": "webhook_secret", - "type": "label", - "hint": { - "en": "Do not change unless instructed by developer.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler." - }, - "label": { - "en": "Webhook secret", - "no": "Webhook secret" - } - } - ] } ] - }, - { + } + ], + "widgets": { + "dashboard": { "name": { - "en": "My Polestar", - "no": "Min Polestar", - "nl": "Mijn Polestar" - }, - "class": "other", - "capabilities": [ - "measure_battery" - ], - "energy": { - "batteries": [ - "INTERNAL" - ] - }, - "platforms": [ - "local" - ], - "connectivity": [ - "cloud" - ], - "images": { - "small": "/drivers/vehicle/assets/images/small.png", - "large": "/drivers/vehicle/assets/images/large.png", - "xlarge": "/drivers/vehicle/assets/images/xlarge.png" + "en": "Vehicle Dashboard" }, - "repair": [ + "height": 188, + "transparent": true, + "settings": [ { - "id": "login" + "id": "device", + "type": "autocomplete", + "title": { + "en": "Vehicle" + } } ], - "pair": [ - { - "id": "login" - }, - { - "id": "list_devices", - "template": "list_devices", - "navigation": { - "next": "add_devices" - } + "api": { + "getVehicles": { + "method": "GET", + "path": "/" }, - { - "id": "add_devices", - "template": "add_devices" + "getVehicleStatus": { + "method": "GET", + "path": "/status" } - ], - "id": "vehicle" + }, + "id": "dashboard" } - ], + }, "capabilities": { + "alarm_polestarOtaAvailable": { + "type": "boolean", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "Software update available", + "no": "Programvareoppdatering tilgjengelig", + "nl": "Software-update beschikbaar" + }, + "getable": true, + "setable": false, + "insights": true + }, + "alarm_polestarTyrePressure": { + "type": "boolean", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/tire-pressure.svg", + "title": { + "en": "Tyre pressure warning", + "no": "Dekktrykk-varsel", + "nl": "Bandenspanningswaarschuwing" + }, + "getable": true, + "setable": false, + "insights": true + }, "measure_polestarAlt": { "type": "number", "uiComponent": "sensor", "icon": "/drivers/polestar-2-csv/assets/images/altitude.svg", "title": { "en": "Altitude", - "no": "Høyde" + "no": "Høyde", + "nl": "Hoogte" }, "units": "m", "getable": true, @@ -795,10 +2292,11 @@ "measure_polestarBattery": { "type": "number", "uiComponent": "sensor", - "icon": "/drivers/polestar-2-csv/assets/images/battery-75.svg", + "icon": "/drivers/vehicle/assets/battery-75.svg", "title": { "en": "Battery", - "no": "Batteri" + "no": "Batteri", + "nl": "Batterij" }, "units": "%", "getable": true, @@ -811,7 +2309,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/hvbattery.svg", "title": { "en": "Battery level", - "no": "Batterinivå" + "no": "Batterinivå", + "nl": "Batterij niveau" }, "units": "kWh", "decimals": 2, @@ -825,7 +2324,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/charging.svg", "title": { "en": "Charging", - "no": "Lader" + "no": "Lader", + "nl": "Laden" }, "getable": true, "setable": false, @@ -846,14 +2346,58 @@ "en": "Min" } }, + "measure_polestarChargingType": { + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/powerdc.svg", + "title": { + "en": "Charging type", + "no": "Ladetype", + "nl": "Laadtype" + }, + "getable": true, + "setable": false, + "insights": false + }, + "measure_polestarClimateRemaining": { + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "Climate time remaining", + "no": "Klima-tid igjen", + "nl": "Climate resterend" + }, + "units": "min", + "decimals": 0, + "getable": true, + "setable": false, + "insights": true + }, "measure_polestarConnected": { "type": "string", "uiComponent": "sensor", "icon": "/drivers/polestar-2-csv/assets/images/ccs.svg", "title": { "en": "Charge port", - "no": "Ladeport" + "no": "Ladeport", + "nl": "Laadpoort" + }, + "getable": true, + "setable": false, + "insights": true + }, + "measure_polestarDrivingKwh": { + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/hvbattery.svg", + "title": { + "en": "Driving energy (total)", + "no": "Kjøreenergi (totalt)", + "nl": "Rijverbruik (totaal)" }, + "units": "kWh", + "decimals": 1, "getable": true, "setable": false, "insights": true @@ -864,7 +2408,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/gear.svg", "title": { "en": "Selected Gear", - "no": "Valgt gir" + "no": "Valgt gir", + "nl": "Geselecteerde versnelling" }, "getable": true, "setable": false, @@ -876,7 +2421,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/ignition.svg", "title": { "en": "Ignition", - "no": "Tenning" + "no": "Tenning", + "nl": "Ontsteking" }, "getable": true, "setable": false, @@ -885,10 +2431,11 @@ "measure_polestarLocation": { "type": "string", "uiComponent": "sensor", - "icon": "/drivers/polestar-2-csv/assets/images/location.svg", + "icon": "/drivers/vehicle/assets/location.svg", "title": { "en": "Location", - "no": "Plassering" + "no": "Plassering", + "nl": "Locatie" }, "getable": true, "setable": false, @@ -900,7 +2447,8 @@ "icon": "/drivers/vehicle/assets/charging.svg", "title": { "en": "Charged home this month", - "no": "Ladet hjemme denne mnd" + "no": "Ladet hjemme denne mnd", + "nl": "Deze maand thuis geladen" }, "getable": true, "setable": false, @@ -921,6 +2469,32 @@ "en": "KM" } }, + "measure_polestarOtaState": { + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "OTA state", + "no": "OTA-status", + "nl": "OTA-status" + }, + "getable": true, + "setable": false, + "insights": false + }, + "measure_polestarOtaVersion": { + "type": "string", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/update.svg", + "title": { + "en": "New software version", + "no": "Ny programvareversjon", + "nl": "Nieuwe softwareversie" + }, + "getable": true, + "setable": false, + "insights": false + }, "measure_polestarPower": { "id": "measure_polestarPower", "type": "number", @@ -928,7 +2502,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/powerdc.svg", "title": { "en": "Power", - "no": "Effekt" + "no": "Effekt", + "nl": "Acceleratie" }, "units": "kW", "getable": true, @@ -941,8 +2516,24 @@ "icon": "/drivers/polestar-2-csv/assets/images/range.svg", "title": { "en": "Range", - "no": "Rekkevidde" + "no": "Rekkevidde", + "nl": "Bereik" + }, + "getable": true, + "setable": false, + "insights": true + }, + "measure_polestarSessionKwh": { + "type": "number", + "uiComponent": "sensor", + "icon": "/drivers/vehicle/assets/charging.svg", + "title": { + "en": "Session charge", + "no": "Øktens lading", + "nl": "Laadsessie" }, + "units": "kWh", + "decimals": 2, "getable": true, "setable": false, "insights": true @@ -953,7 +2544,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/speedometer.svg", "title": { "en": "Speed", - "no": "Fart" + "no": "Fart", + "nl": "Snelheid" }, "units": { "en": "km/h", @@ -969,7 +2561,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/temp.svg", "title": { "en": "Ambient Temperature", - "no": "Utetemperatur" + "no": "Utetemperatur", + "nl": "Buitentemperatuur" }, "units": "°C", "getable": true, @@ -982,7 +2575,8 @@ "icon": "/drivers/polestar-2-csv/assets/images/update.svg", "title": { "en": "Updated", - "no": "Oppdatert" + "no": "Oppdatert", + "nl": "Bijgewerkt" }, "getable": true, "setable": false, @@ -1018,7 +2612,7 @@ "type": "boolean", "title": { "en": "Chargeport connected", - "no": "Ladepoort tilkoblet", + "no": "Ladeport tilkoblet", "nl": "Laadpoort verbonden" }, "getable": true, @@ -1026,6 +2620,31 @@ "uiComponent": "sensor", "icon": "/drivers/vehicle/assets/charger_connected.svg" }, + "measure_vehicleDaysTillService": { + "type": "number", + "title": { + "en": "Days till service", + "no": "Dager til service", + "nl": "Dagen tot service" + }, + "getable": true, + "setable": false, + "icon": "/drivers/vehicle/assets/timeremaining.svg" + }, + "measure_vehicleDistanceTillService": { + "type": "number", + "title": { + "en": "Distance till service", + "no": "Avstand til service", + "nl": "Afstand tot service" + }, + "getable": true, + "setable": false, + "units": { + "en": "KM" + }, + "icon": "/drivers/vehicle/assets/odometer.svg" + }, "measure_vehicleOdometer": { "type": "number", "title": { @@ -1053,6 +2672,42 @@ "en": "KM" }, "icon": "/drivers/vehicle/assets/range.svg" + }, + "target_polestarAmpLimit": { + "type": "number", + "uiComponent": "slider", + "icon": "/drivers/vehicle/assets/charging.svg", + "title": { + "en": "Charging amp limit", + "no": "Amperegrense ved lading", + "nl": "Ampère-limiet bij laden" + }, + "units": "A", + "decimals": 0, + "min": 6, + "max": 32, + "step": 1, + "getable": true, + "setable": true, + "insights": true + }, + "target_polestarChargeLimit": { + "type": "number", + "uiComponent": "slider", + "icon": "/drivers/vehicle/assets/battery-100.svg", + "title": { + "en": "Charge limit", + "no": "Ladegrense", + "nl": "Laadlimiet" + }, + "units": "%", + "decimals": 0, + "min": 50, + "max": 100, + "step": 5, + "getable": true, + "setable": true, + "insights": true } } } \ No newline at end of file diff --git a/assets/images/preview-dark.png b/assets/images/preview-dark.png new file mode 100644 index 0000000..521f68d Binary files /dev/null and b/assets/images/preview-dark.png differ diff --git a/assets/images/preview-light.png b/assets/images/preview-light.png new file mode 100644 index 0000000..632cdac Binary files /dev/null and b/assets/images/preview-light.png differ diff --git a/clone_modules/polestar-c3/auth.js b/clone_modules/polestar-c3/auth.js new file mode 100644 index 0000000..9a5007d --- /dev/null +++ b/clone_modules/polestar-c3/auth.js @@ -0,0 +1,200 @@ +'use strict'; + +const crypto = require('crypto'); +const axios = require('axios'); +const qs = require('qs'); + +const OIDC_PROVIDER = 'https://polestarid.eu.polestar.com'; +const OIDC_DISCOVERY = `${OIDC_PROVIDER}/.well-known/openid-configuration`; +const CLIENT_ID = 'lp8dyrd_10'; +const REDIRECT_URI = 'polestar-explore://explore.polestar.com'; +const SCOPES = 'openid profile email customer:attributes customer:attributes:write'; + +function b64url(buf) { + return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function generatePkce() { + const verifier = b64url(crypto.randomBytes(32)); + const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); + return { verifier, challenge }; +} + +function generateState() { + return b64url(crypto.randomBytes(32)); +} + +class AuthManager { + constructor() { + this._tokens = null; + this._authEndpoint = null; + this._tokenEndpoint = null; + } + + get accessToken() { return this._tokens ? this._tokens.access_token : null; } + get isExpired() { + if (!this._tokens) return true; + if (!this._tokens.expires_at) return false; + return Date.now() > this._tokens.expires_at - 60_000; + } + + async _discover() { + if (this._authEndpoint && this._tokenEndpoint) return; + const r = await axios.get(OIDC_DISCOVERY, { timeout: 30000 }); + this._authEndpoint = r.data.authorization_endpoint; + this._tokenEndpoint = r.data.token_endpoint; + } + + async authenticate(email, password) { + this._email = email; + this._password = password; + await this._discover(); + await this._fullAuth(email, password); + } + + async ensureValidToken() { + if (!this._tokens) throw new Error('Not authenticated'); + if (this.isExpired) { + if (this._tokens.refresh_token) { + try { await this._refresh(); return this._tokens.access_token; } + catch (_) { /* fall through */ } + } + if (this._email && this._password) { + await this._fullAuth(this._email, this._password); + } else { + throw new Error('Token expired and no credentials available'); + } + } + return this._tokens.access_token; + } + + async _fullAuth(email, password) { + const { verifier, challenge } = generatePkce(); + const code = await this._authorize(challenge, email, password); + await this._exchange(code, verifier); + } + + async _authorize(codeChallenge, email, password) { + const state = generateState(); + const params = { + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: SCOPES, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + response_mode: 'query', + }; + + // Step 1: follow through to login form, collecting cookies. + const jar = []; + const collectCookies = (res) => { + const sc = res.headers && res.headers['set-cookie']; + if (sc) for (const c of sc) jar.push(c.split(';')[0]); + }; + const cookieHeader = () => jar.join('; '); + + let r = await axios.get(this._authEndpoint, { + params, + maxRedirects: 0, + validateStatus: () => true, + }); + collectCookies(r); + // Follow redirects manually so we can keep cookies. + while (r.status >= 300 && r.status < 400 && r.headers.location) { + const loc = r.headers.location.startsWith('http') ? r.headers.location : OIDC_PROVIDER + r.headers.location; + r = await axios.get(loc, { maxRedirects: 0, validateStatus: () => true, headers: { cookie: cookieHeader() } }); + collectCookies(r); + } + + const html = typeof r.data === 'string' ? r.data : ''; + const m = html.match(/(?:url|action)\s*:\s*"([^"]+)"/); + if (!m) throw new Error(`Auth page did not contain resume URL (status ${r.status})`); + const resumeUrl = m[1].startsWith('http') ? m[1] : OIDC_PROVIDER + m[1]; + + // Step 2: post credentials. + r = await axios.post(resumeUrl, qs.stringify({ 'pf.username': email, 'pf.pass': password }), { + params, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + cookie: cookieHeader(), + }, + maxRedirects: 0, + validateStatus: () => true, + }); + collectCookies(r); + + if (r.status !== 302 && r.status !== 303) { + const body = typeof r.data === 'string' ? r.data : JSON.stringify(r.data); + if (body.includes('ERR001')) throw new Error('Invalid username or password'); + throw new Error(`Auth failed with status ${r.status}`); + } + + let location = r.headers.location || ''; + let parsed = new URL(location, OIDC_PROVIDER); + let code = parsed.searchParams.get('code'); + const uid = parsed.searchParams.get('uid'); + + // Terms & Conditions flow. + if (!code && uid) { + r = await axios.post(resumeUrl, qs.stringify({ 'pf.submit': 'true', subject: uid }), { + params, + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: cookieHeader() }, + maxRedirects: 0, + validateStatus: () => true, + }); + collectCookies(r); + if (r.status === 302 || r.status === 303) { + location = r.headers.location || ''; + parsed = new URL(location, OIDC_PROVIDER); + code = parsed.searchParams.get('code'); + } + } + + if (!code) throw new Error(`No auth code in redirect: ${location}`); + return code; + } + + async _exchange(code, verifier) { + const r = await axios.post(this._tokenEndpoint, qs.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: verifier, + }), { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + validateStatus: () => true, + timeout: 30000, + }); + if (r.status !== 200) throw new Error(`Token exchange failed: ${r.status} ${JSON.stringify(r.data)}`); + this._storeTokens(r.data); + } + + async _refresh() { + const r = await axios.post(this._tokenEndpoint, qs.stringify({ + grant_type: 'refresh_token', + refresh_token: this._tokens.refresh_token, + client_id: CLIENT_ID, + }), { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + validateStatus: () => true, + timeout: 30000, + }); + if (r.status !== 200) throw new Error(`Refresh failed: ${r.status}`); + this._storeTokens(r.data); + } + + _storeTokens(data) { + this._tokens = { + access_token: data.access_token, + refresh_token: data.refresh_token || (this._tokens && this._tokens.refresh_token) || null, + token_type: data.token_type || 'Bearer', + expires_in: data.expires_in || 0, + expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 : 0, + }; + } +} + +module.exports = { AuthManager, CLIENT_ID, REDIRECT_URI }; diff --git a/clone_modules/polestar-c3/chronos.js b/clone_modules/polestar-c3/chronos.js new file mode 100644 index 0000000..f644c0c --- /dev/null +++ b/clone_modules/polestar-c3/chronos.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * ChronosRequest envelope used by all /chronos.services.v1.* gRPC services + * (ChargeNow, TargetSoc, AmpLimit, ChargeTimer, ParkingClimateTimer). + * + * Wire layout: + * field 1: string id — random UUID per call + * field 2: string vin + * field 3: string source — always "RCS" + * field 4: TimeZone { + * field 1: int32 offset_minutes + * } + * + * Outer request wraps ChronosRequest at field 1, then appends any + * service-specific fields (level, amperage, etc.) at fields 2+. + */ + +const { randomUUID } = require('crypto'); +const codec = require('./codec'); + +const CHRONOS_REQUEST_SCHEMA = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + source: { num: 3, type: 'string' }, + time_zone: { num: 4, type: 'message' }, +}; + +const TIMEZONE_SCHEMA = { + offset_minutes: { num: 1, type: 'int32' }, +}; + +function utcOffsetMinutes() { + // Node returns minutes WEST of UTC (positive = behind UTC). Polestar wants + // minutes EAST of UTC (positive = ahead), matching Python's utcoffset(). + return -new Date().getTimezoneOffset(); +} + +function buildChronosRequest(vin) { + const tz = codec.encode(TIMEZONE_SCHEMA, { offset_minutes: utcOffsetMinutes() }); + return codec.encode(CHRONOS_REQUEST_SCHEMA, { + id: randomUUID(), + vin, + source: 'RCS', + time_zone: tz, + }); +} + +function wrapChronos(vin, innerPayload = Buffer.alloc(0)) { + // Field 1 = ChronosRequest (length-delimited nested message), followed by + // raw payload bytes (already encoded with tags for field 2+). + return Buffer.concat([ + codec.encodeField(1, 'message', buildChronosRequest(vin)), + innerPayload, + ]); +} + +module.exports = { buildChronosRequest, wrapChronos }; diff --git a/clone_modules/polestar-c3/client.js b/clone_modules/polestar-c3/client.js new file mode 100644 index 0000000..3bf83c5 --- /dev/null +++ b/clone_modules/polestar-c3/client.js @@ -0,0 +1,508 @@ +'use strict'; + +const { AuthManager } = require('./auth'); +const { discoverC3Endpoint, getVehicles } = require('./discovery'); +const grpc = require('./grpc'); +const codec = require('./codec'); +const { wrapChronos } = require('./chronos'); +const { + VehicleRequestSchema, + GetBatteryResponseSchema, + GetOdometerResponseSchema, + GetHealthResponseSchema, + GetExteriorResponseSchema, + GetClimateResponseSchema, + InvocationRequestSchema, + CarLockRequestSchema, + CarUnlockRequestSchema, + HonkFlashRequestSchema, + ClimatizationStartRequestSchema, + ClimatizationStopRequestSchema, + InvocationResponseEnvelopeSchema, + InvocationStatus, + HonkFlashAction, + HeatingIntensity, + WindowControlRequestSchema, + WindowControlType, + GetSoftwareInfoResponseSchema, + SoftwareState, + OTA_AVAILABLE_STATES, + ChargingStatus, + ChargerConnectionStatus, + ChargingType, + ServiceWarning, + TyrePressureWarning, + OpenStatus, + LockStatus, + ClimatizationRunningStatus, + ClimatizationRequestType, +} = require('./messages'); + +const SVC_BATTERY = '/services.vehiclestates.battery.BatteryService'; +const SVC_ODOMETER = '/services.vehiclestates.odometer.OdometerService'; +const SVC_HEALTH = '/services.vehiclestates.health.HealthService'; +const SVC_EXTERIOR = '/services.vehiclestates.exterior.ExteriorService'; +const SVC_CLIMATE = '/services.vehiclestates.parkingclimatization.ParkingClimatizationService'; +const SVC_CHARGE_NOW = '/chronos.services.v1.ChargeNowService'; +const SVC_TARGET_SOC = '/chronos.services.v1.TargetSocService'; +const SVC_AMP_LIMIT = '/chronos.services.v1.AmpLimitService'; +const SVC_INVOCATION = '/invocation.InvocationService'; +const SVC_LOCATION = '/dtlinternet.DtlInternetService'; +const SVC_OTA_DISCOVERY = '/ota_mobcache.OtaDiscoveryService'; + +// ChargeTargetLevelSettingType enum +const CHARGE_TARGET_DAILY = 1; +const CHARGE_TARGET_LONG_TRIP = 2; +const CHARGE_TARGET_CUSTOM = 3; + +class PolestarC3 { + constructor(email, password) { + this._auth = new AuthManager(); + this._email = email; + this._password = password; + this._endpoint = null; + this._session = null; + this._vin = null; + } + + async login() { + await this._auth.authenticate(this._email, this._password); + this._endpoint = await discoverC3Endpoint(this._auth.accessToken); + } + + async listVehicles() { + const token = await this._auth.ensureValidToken(); + return getVehicles(token); + } + + async setVehicle(vin) { + this._vin = vin; + } + + _ensureSession() { + if (this._session && !this._session.closed && !this._session.destroyed) return this._session; + const session = grpc.connect(this._endpoint.host, this._endpoint.port); + session.setTimeout(60000); + session.on('error', () => { /* swallow to allow reconnect */ }); + session.on('close', () => { if (this._session === session) this._session = null; }); + session.on('goaway', () => { + console.warn('[polestar-c3] HTTP/2 GOAWAY received, resetting session'); + if (this._session === session) this._session = null; + try { session.destroy(); } catch (_) {} + }); + // Heartbeat ping every 30s — matches Python reference keepalive and keeps + // intermediate load balancers from idling us out. + const heartbeat = setInterval(() => { + if (session.closed || session.destroyed) { clearInterval(heartbeat); return; } + try { + session.ping((err) => { + if (err) { + console.warn('[polestar-c3] ping failed, destroying session:', err.message); + if (this._session === session) this._session = null; + try { session.destroy(); } catch (_) {} + } + }); + } catch (_) { /* session already gone */ } + }, 30000); + heartbeat.unref && heartbeat.unref(); + session.once('close', () => clearInterval(heartbeat)); + this._session = session; + return session; + } + + async _call(method, requestBytes, { debug = false, streaming = false, retries = 1 } = {}) { + let lastErr = null; + for (let attempt = 0; attempt <= retries; attempt++) { + const token = await this._auth.ensureValidToken(); + const session = this._ensureSession(); + const metadata = { authorization: `Bearer ${token}` }; + if (this._vin) metadata.vin = this._vin; + const fn = streaming ? grpc.serverStreamFirst : grpc.unaryUnary; + try { + return await fn(session, method, requestBytes, metadata, { debug }); + } catch (err) { + lastErr = err; + const msg = err.message || ''; + const transient = /\bstatus=(13|14)\b|GOAWAY|goaway|ECONNRESET|EPIPE|ETIMEDOUT|NGHTTP2_/i.test(msg); + if (attempt < retries && transient) { + console.warn(`[polestar-c3] transient error on ${method}, retrying (${msg})`); + this._session = null; + continue; + } + throw err; + } + } + throw lastErr; + } + + _vehicleRequestBytes() { + if (!this._vin) throw new Error('No vehicle selected — call setVehicle(vin) first'); + return codec.encode(VehicleRequestSchema, { vin: this._vin }); + } + + async getLatestBattery({ debug = false } = {}) { + const req = this._vehicleRequestBytes(); + const respBytes = await this._call(`${SVC_BATTERY}/GetLatestBattery`, req, { debug }); + const decoded = codec.decode(GetBatteryResponseSchema, respBytes); + if (decoded.battery) { + decoded.battery.charging_status_label = ChargingStatus[decoded.battery.charging_status] || null; + decoded.battery.charger_connection_status_label = + ChargerConnectionStatus[decoded.battery.charger_connection_status] || null; + decoded.battery.charging_type_label = ChargingType[decoded.battery.charging_type] || null; + } + return decoded; + } + + async getLatestOdometer({ debug = false } = {}) { + const req = this._vehicleRequestBytes(); + const respBytes = await this._call(`${SVC_ODOMETER}/GetOdometer`, req, { debug, streaming: true }); + return codec.decode(GetOdometerResponseSchema, respBytes); + } + + async getLatestHealth({ debug = false } = {}) { + const req = this._vehicleRequestBytes(); + const respBytes = await this._call(`${SVC_HEALTH}/GetHealth`, req, { debug, streaming: true }); + const decoded = codec.decode(GetHealthResponseSchema, respBytes); + if (decoded.health) { + decoded.health.service_warning_label = ServiceWarning[decoded.health.service_warning] || null; + for (const side of ['front_left', 'front_right', 'rear_left', 'rear_right']) { + const key = `${side}_tyre_pressure_warning`; + decoded.health[`${key}_label`] = TyrePressureWarning[decoded.health[key]] || null; + } + } + return decoded; + } + + async getLatestExterior({ debug = false } = {}) { + const req = this._vehicleRequestBytes(); + const respBytes = await this._call(`${SVC_EXTERIOR}/GetLatestExterior`, req, { debug }); + const decoded = codec.decode(GetExteriorResponseSchema, respBytes); + if (decoded.exterior) { + const e = decoded.exterior; + e.central_lock_label = LockStatus[e.central_lock] || null; + e.tailgate_lock_label = LockStatus[e.tailgate_lock] || null; + for (const key of ['door_front_left', 'door_front_right', 'door_rear_left', 'door_rear_right', + 'window_front_left', 'window_front_right', 'window_rear_left', 'window_rear_right', + 'hood', 'tailgate', 'tank_lid', 'sunroof']) { + e[`${key}_label`] = OpenStatus[e[key]] || null; + } + } + return decoded; + } + + async getLatestClimate({ debug = false } = {}) { + const req = this._vehicleRequestBytes(); + const respBytes = await this._call(`${SVC_CLIMATE}/GetLatestParkingClimatization`, req, { debug }); + const decoded = codec.decode(GetClimateResponseSchema, respBytes); + if (decoded.climate) { + const c = decoded.climate; + c.running_status_label = ClimatizationRunningStatus[c.running_status] || null; + c.request_type_label = ClimatizationRequestType[c.request_type] || null; + } + return decoded; + } + + // --- Invocation write helpers (lock, unlock, honk/flash, climate) --- + + _parseInvocationResponse(respBytes) { + const env = codec.decode(InvocationResponseEnvelopeSchema, respBytes); + const r = env.response || {}; + const statusLabel = InvocationStatus[r.status] || null; + return { + id: r.id || null, + vin: r.vin || null, + status: typeof r.status === 'number' ? r.status : null, + statusLabel, + message: r.message || null, + timestamp: typeof r.timestamp === 'bigint' ? Number(r.timestamp) : (r.timestamp || null), + ok: r.status === 1 || r.status === 4 || r.status === 6, // SENT / DELIVERED / SUCCESS + }; + } + + async _invocationCall(method, requestBytes, { debug = false } = {}) { + // Invocation methods are server-streaming; we take the first delivered + // response (often SENT or DELIVERED — the car processes the command + // regardless of whether we hang around for the final SUCCESS). + const respBytes = await this._call(`${SVC_INVOCATION}/${method}`, requestBytes, { debug, streaming: true }); + const parsed = this._parseInvocationResponse(respBytes); + if (!parsed.ok) { + throw new Error(`Invocation ${method} failed: status=${parsed.status} (${parsed.statusLabel}) ${parsed.message || ''}`.trim()); + } + return parsed; + } + + async lock({ debug = false } = {}) { + const req = codec.encode(CarLockRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + lock_type: 0, // LOCK + }); + return this._invocationCall('Lock', req, { debug }); + } + + async unlock({ debug = false } = {}) { + const req = codec.encode(CarUnlockRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + unlock_type: 0, // full unlock + }); + return this._invocationCall('Unlock', req, { debug }); + } + + async unlockTrunk({ debug = false } = {}) { + const req = codec.encode(CarUnlockRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + unlock_type: 1, // trunk only + }); + return this._invocationCall('Unlock', req, { debug }); + } + + async honkFlash({ action = HonkFlashAction.FLASH, debug = false } = {}) { + const req = codec.encode(HonkFlashRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + honk_flash_type: action, + }); + return this._invocationCall('HonkFlash', req, { debug }); + } + + async climateStart(options = {}) { + const { + temperature = 21, + frontLeftSeat = HeatingIntensity.UNSPECIFIED, + frontRightSeat = HeatingIntensity.UNSPECIFIED, + rearLeftSeat = HeatingIntensity.UNSPECIFIED, + rearRightSeat = HeatingIntensity.UNSPECIFIED, + steeringWheel = HeatingIntensity.UNSPECIFIED, + debug = false, + } = options; + const req = codec.encode(ClimatizationStartRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + start: true, + compartment_temperature_celsius: Number(temperature), + front_left_seat: frontLeftSeat, + front_right_seat: frontRightSeat, + rear_left_seat: rearLeftSeat, + rear_right_seat: rearRightSeat, + steering_wheel: steeringWheel, + }); + return this._invocationCall('ClimatizationStart', req, { debug }); + } + + async climateStop({ debug = false } = {}) { + const req = codec.encode(ClimatizationStopRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + }); + return this._invocationCall('ClimatizationStop', req, { debug }); + } + + async windowsOpen({ debug = false } = {}) { + const req = codec.encode(WindowControlRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + windows_control: WindowControlType.OPEN_ALL, + }); + return this._invocationCall('WindowControl', req, { debug }); + } + + async windowsClose({ debug = false } = {}) { + const req = codec.encode(WindowControlRequestSchema, { + request: codec.encode(InvocationRequestSchema, { vin: this._vin }), + windows_control: WindowControlType.CLOSE_ALL, + }); + return this._invocationCall('WindowControl', req, { debug }); + } + + async getOtaSoftwareInfo({ debug = false } = {}) { + const req = codec.encode( + { vin: { num: 1, type: 'string' }, locale: { num: 2, type: 'string' } }, + { vin: this._vin, locale: 'en' }, + ); + const respBytes = await this._call(`${SVC_OTA_DISCOVERY}/GetSoftwareInfo`, req, { debug, streaming: true }); + const decoded = codec.decode(GetSoftwareInfoResponseSchema, respBytes); + if (!decoded.info) return null; + const info = decoded.info; + info.state_label = SoftwareState[info.state] || null; + info.update_available = OTA_AVAILABLE_STATES.has(info.state); + return info; + } + + async getLastKnownLocation({ debug = false } = {}) { + // Location service uses VIN at field 1 (not VehicleRequest at field 1/2). + const req = codec.encode({ vin: { num: 1, type: 'string' } }, { vin: this._vin }); + const respBytes = await this._call(`${SVC_LOCATION}/GetLastKnownLocation`, req, { debug }); + return this._parseLocationResponse(respBytes); + } + + async getLastParkedLocation({ debug = false } = {}) { + const req = codec.encode({ vin: { num: 1, type: 'string' } }, { vin: this._vin }); + const respBytes = await this._call(`${SVC_LOCATION}/GetLastParkedLocation`, req, { debug }); + return this._parseLocationResponse(respBytes); + } + + /** + * Location responses come in three shapes depending on backend version: + * A) outer[5] = nested-location-bytes (most common on C3) + * B) outer[2] = nested-location-bytes + * C) outer[2]=longitude_float, outer[3]=latitude_float, outer[4]=timestamp + * Inner "compact" layout: [1]=longitude, [2]=latitude, [3]=timestamp. + */ + _parseLocationResponse(respBytes) { + const raw = codec.decodeRaw(respBytes); + // Variant A/B: nested compact location at field 5 or 2 + for (const key of ['field5(wt=2)', 'field2(wt=2)']) { + const nested = raw[key]; + if (Buffer.isBuffer(nested)) { + const parsed = this._parseCompactLocation(nested); + if (parsed) return parsed; + } + } + // Variant C: flat fields + const lng = raw['field2(wt=5)'] ?? raw['field2(wt=1)']; + const lat = raw['field3(wt=5)'] ?? raw['field3(wt=1)']; + if (typeof lng === 'number' && typeof lat === 'number') { + const ts = raw['field4(wt=0)']; + return { longitude: lng, latitude: lat, timestamp: typeof ts === 'number' ? ts : null }; + } + return null; + } + + _parseCompactLocation(bytes) { + const raw = codec.decodeRaw(bytes); + const lng = raw['field1(wt=5)'] ?? raw['field1(wt=1)']; + const lat = raw['field2(wt=5)'] ?? raw['field2(wt=1)']; + if (typeof lng !== 'number' || typeof lat !== 'number') return null; + let ts = null; + const tsField = raw['field3(wt=2)'] ?? raw['field3(wt=0)']; + if (Buffer.isBuffer(tsField)) { + const tsRaw = codec.decodeRaw(tsField); + const seconds = tsRaw['field1(wt=0)']; + ts = typeof seconds === 'number' ? seconds : null; + } else if (typeof tsField === 'number') { + ts = tsField; + } + return { longitude: lng, latitude: lat, timestamp: ts }; + } + + // --- Chronos write helpers --- + + _unwrapChronosPayload(bytes) { + // Chronos responses wrap the payload at field 3 of a length-delimited outer message. + const raw = codec.decode({ payload: { num: 3, type: 'bytes' } }, bytes); + return raw.payload || null; + } + + async _chronosCall(method, innerPayload, { streaming = false, debug = false } = {}) { + if (!this._vin) throw new Error('No vehicle selected'); + const req = wrapChronos(this._vin, innerPayload); + if (debug || process.env.POLESTAR_DUMP_REQUESTS === '1') { + console.log(`[polestar-c3 dump] ${method} REQUEST size=${req.length} hex=${req.toString('hex')}`); + try { + const raw = codec.decodeRaw(req); + const summary = {}; + for (const [k, v] of Object.entries(raw)) { + summary[k] = Buffer.isBuffer(v) ? `<${v.length}B hex=${v.toString('hex')}>` : v; + } + console.log(`[polestar-c3 dump] ${method} REQUEST decoded:`, summary); + } catch (_) {} + } + return this._call(method, req, { streaming, debug }); + } + + async chargeStart({ debug = false } = {}) { + const resp = await this._chronosCall(`${SVC_CHARGE_NOW}/StartOverrideChargeTimer`, Buffer.alloc(0), { debug }); + return this._parseStatusCode(resp); + } + + async chargeStop({ debug = false } = {}) { + const resp = await this._chronosCall(`${SVC_CHARGE_NOW}/StopOverrideChargeTimer`, Buffer.alloc(0), { debug }); + return this._parseStatusCode(resp); + } + + async getTargetSoc({ debug = false } = {}) { + // Server-streaming, take first message. + const resp = await this._chronosCall(`${SVC_TARGET_SOC}/GetTargetSoc`, Buffer.alloc(0), { streaming: true, debug }); + return this._parseIntegerField(resp, 1); // inner field 1 = target_level + } + + async setTargetSoc(level, settingType = CHARGE_TARGET_DAILY, { debug = false } = {}) { + if (!Number.isInteger(level) || level < 0 || level > 100) { + throw new Error(`Target SoC out of range: ${level}`); + } + const inner = codec.encode({ + level: { num: 2, type: 'int32' }, + setting_type: { num: 3, type: 'int32' }, + }, { level, setting_type: settingType }); + // The response on Polestar 4 is a chronos-envelope ack (id, vin, + // setting_type_echo) and does NOT echo the newly committed level — + // field 1 of the inner payload can be stale. Callers should verify + // via a subsequent GetTargetSoc instead of trusting the return here. + const resp = await this._chronosCall(`${SVC_TARGET_SOC}/SetTargetSoc`, inner, { streaming: true, debug }); + return this._parseIntegerField(resp, 1); + } + + async getAmpLimit({ debug = false } = {}) { + const resp = await this._chronosCall(`${SVC_AMP_LIMIT}/GetAmpLimit`, Buffer.alloc(0), { streaming: true, debug }); + return this._parseIntegerField(resp, 1); // inner field 1 = amperage_limit + } + + async setAmpLimit(amperage, { debug = false } = {}) { + if (!Number.isInteger(amperage) || amperage < 6 || amperage > 32) { + throw new Error(`Amp limit out of range (6–32): ${amperage}`); + } + const inner = codec.encode({ + amp_limit: { num: 2, type: 'int32' }, + }, { amp_limit: amperage }); + const resp = await this._chronosCall(`${SVC_AMP_LIMIT}/SetAmpLimit`, inner, { debug }); + return this._parseIntegerField(resp, 1); + } + + /** Best-effort dump of a chronos response so we can see what the server + * actually sent back without writing proto schemas for every response type. */ + _debugDumpChronos(label, respBytes) { + try { + const raw = codec.decodeRaw(respBytes); + const summarize = (obj) => { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = Buffer.isBuffer(v) ? `<${v.length}B hex=${v.toString('hex')}>` : v; + } + return out; + }; + console.log(`[polestar-c3 dump] ${label} size=${respBytes.length}:`, summarize(raw)); + // Recursively decode any nested messages (length-delimited with wt=2). + for (const [key, val] of Object.entries(raw)) { + if (Buffer.isBuffer(val)) { + try { + const inner = codec.decodeRaw(val); + if (Object.keys(inner).length) { + console.log(`[polestar-c3 dump] ${label} ${key} nested:`, summarize(inner)); + } + } catch (_) { /* not a valid proto message */ } + } + } + } catch (err) { + console.log(`[polestar-c3 dump] ${label} decode failed:`, err.message); + } + } + + _parseStatusCode(respBytes) { + const payload = this._unwrapChronosPayload(respBytes); + if (!payload) return 0; + const decoded = codec.decode({ status: { num: 1, type: 'int32' } }, payload); + return decoded.status || 0; + } + + _parseIntegerField(respBytes, fieldNum) { + const payload = this._unwrapChronosPayload(respBytes); + if (!payload) return null; + const schema = { value: { num: fieldNum, type: 'int32' } }; + const decoded = codec.decode(schema, payload); + return typeof decoded.value === 'number' ? decoded.value : null; + } + + close() { + if (this._session && !this._session.closed) { + try { this._session.close(); } catch (_) { /* noop */ } + } + this._session = null; + } +} + +module.exports = { PolestarC3 }; diff --git a/clone_modules/polestar-c3/codec.js b/clone_modules/polestar-c3/codec.js new file mode 100644 index 0000000..e1696d1 --- /dev/null +++ b/clone_modules/polestar-c3/codec.js @@ -0,0 +1,235 @@ +'use strict'; + +const WIRE_VARINT = 0; +const WIRE_FIXED64 = 1; +const WIRE_LEN = 2; +const WIRE_START_GROUP = 3; +const WIRE_END_GROUP = 4; +const WIRE_FIXED32 = 5; + +function encodeVarint(value) { + if (typeof value === 'number') value = BigInt(Math.trunc(value)); + if (value < 0n) value = value & 0xFFFFFFFFFFFFFFFFn; + const bytes = []; + while (value > 0x7Fn) { + bytes.push(Number((value & 0x7Fn) | 0x80n)); + value >>= 7n; + } + bytes.push(Number(value & 0x7Fn)); + return Buffer.from(bytes); +} + +function decodeVarint(buf, pos) { + let result = 0n; + let shift = 0n; + while (true) { + const b = buf[pos++]; + result |= BigInt(b & 0x7F) << shift; + if ((b & 0x80) === 0) break; + shift += 7n; + } + return [result, pos]; +} + +function encodeTag(fieldNumber, wireType) { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeLenDelim(fieldNumber, payload) { + return Buffer.concat([encodeTag(fieldNumber, WIRE_LEN), encodeVarint(payload.length), payload]); +} + +function encodeField(fieldNumber, type, value) { + switch (type) { + case 'string': { + const enc = Buffer.from(String(value), 'utf8'); + return encodeLenDelim(fieldNumber, enc); + } + case 'bytes': + return encodeLenDelim(fieldNumber, Buffer.from(value)); + case 'int32': + case 'int64': + case 'uint32': + case 'uint64': + case 'enum': + return Buffer.concat([encodeTag(fieldNumber, WIRE_VARINT), encodeVarint(value)]); + case 'bool': + return Buffer.concat([encodeTag(fieldNumber, WIRE_VARINT), encodeVarint(value ? 1 : 0)]); + case 'double': { + const b = Buffer.alloc(8); + b.writeDoubleLE(Number(value), 0); + return Buffer.concat([encodeTag(fieldNumber, WIRE_FIXED64), b]); + } + case 'float': { + const b = Buffer.alloc(4); + b.writeFloatLE(Number(value), 0); + return Buffer.concat([encodeTag(fieldNumber, WIRE_FIXED32), b]); + } + case 'message': + return encodeLenDelim(fieldNumber, Buffer.from(value)); + default: + throw new Error(`Unknown field type: ${type}`); + } +} + +function encode(schema, obj) { + const parts = []; + for (const [name, spec] of Object.entries(schema)) { + if (!(name in obj)) continue; + const val = obj[name]; + if (val === null || val === undefined) continue; + if (spec.type === 'message' && val && typeof val === 'object' && !Buffer.isBuffer(val)) { + if (!spec.schema) throw new Error(`Nested schema missing for field ${name}`); + const nested = encode(spec.schema, val); + parts.push(encodeField(spec.num, 'message', nested)); + } else { + parts.push(encodeField(spec.num, spec.type, val)); + } + } + return Buffer.concat(parts); +} + +function skipGroup(buf, pos, fieldNumber) { + while (pos < buf.length) { + let tag; + [tag, pos] = decodeVarint(buf, pos); + const t = Number(tag); + const wt = t & 0x07; + const fn = t >> 3; + if (wt === WIRE_END_GROUP && fn === fieldNumber) return pos; + switch (wt) { + case WIRE_VARINT: [, pos] = decodeVarint(buf, pos); break; + case WIRE_FIXED64: pos += 8; break; + case WIRE_LEN: { + let len; [len, pos] = decodeVarint(buf, pos); + pos += Number(len); + break; + } + case WIRE_START_GROUP: pos = skipGroup(buf, pos, fn); break; + case WIRE_FIXED32: pos += 4; break; + default: throw new Error(`Unknown wire type ${wt}`); + } + } + return pos; +} + +function decode(schema, buf) { + const byNum = {}; + for (const [name, spec] of Object.entries(schema)) { + byNum[spec.num] = { name, ...spec }; + } + + const result = {}; + let pos = 0; + while (pos < buf.length) { + let tag; + [tag, pos] = decodeVarint(buf, pos); + const t = Number(tag); + const fn = t >> 3; + const wt = t & 0x07; + let raw; + + switch (wt) { + case WIRE_VARINT: [raw, pos] = decodeVarint(buf, pos); break; + case WIRE_FIXED64: { + raw = buf.readDoubleLE(pos); + pos += 8; + break; + } + case WIRE_LEN: { + let len; [len, pos] = decodeVarint(buf, pos); + const l = Number(len); + raw = buf.slice(pos, pos + l); + pos += l; + break; + } + case WIRE_START_GROUP: pos = skipGroup(buf, pos, fn); continue; + case WIRE_END_GROUP: continue; + case WIRE_FIXED32: { + raw = buf.readFloatLE(pos); + pos += 4; + break; + } + default: throw new Error(`Unknown wire type ${wt}`); + } + + const spec = byNum[fn]; + if (!spec) continue; // unknown field, skip + + let value; + switch (spec.type) { + case 'string': value = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw); break; + case 'bytes': value = raw; break; + case 'bool': value = Boolean(Number(raw)); break; + case 'int32': + case 'uint32': + case 'enum': value = Number(raw); break; + case 'int64': + case 'uint64': { + const asBig = typeof raw === 'bigint' ? raw : BigInt(raw); + value = asBig <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(asBig) : asBig; + break; + } + case 'double': + case 'float': value = Number(raw); break; + case 'message': { + if (!spec.schema) { value = raw; break; } + value = decode(spec.schema, raw); + break; + } + default: value = raw; + } + + if (spec.name in result) { + if (!Array.isArray(result[spec.name])) result[spec.name] = [result[spec.name]]; + result[spec.name].push(value); + } else { + result[spec.name] = value; + } + } + return result; +} + +/** Raw decoder that keeps every field keyed by number, ignoring any schema. + * Intended for debugging/introspection of responses whose layout we don't know yet. */ +function decodeRaw(buf) { + const result = {}; + let pos = 0; + while (pos < buf.length) { + let tag; + [tag, pos] = decodeVarint(buf, pos); + const t = Number(tag); + const fn = t >> 3; + const wt = t & 0x07; + let raw; + switch (wt) { + case WIRE_VARINT: [raw, pos] = decodeVarint(buf, pos); break; + case WIRE_FIXED64: raw = buf.readDoubleLE(pos); pos += 8; break; + case WIRE_LEN: { + let len; [len, pos] = decodeVarint(buf, pos); + const l = Number(len); + raw = buf.slice(pos, pos + l); + pos += l; + break; + } + case WIRE_FIXED32: raw = buf.readFloatLE(pos); pos += 4; break; + default: return result; // unknown / group / end-group: stop parsing + } + if (typeof raw === 'bigint' && raw <= BigInt(Number.MAX_SAFE_INTEGER)) raw = Number(raw); + const key = `field${fn}(wt=${wt})`; + if (key in result) { + if (!Array.isArray(result[key])) result[key] = [result[key]]; + result[key].push(raw); + } else { + result[key] = raw; + } + } + return result; +} + +module.exports = { + encodeVarint, decodeVarint, + encodeField, encodeLenDelim, + encode, decode, decodeRaw, + WIRE_VARINT, WIRE_FIXED64, WIRE_LEN, WIRE_FIXED32, +}; diff --git a/clone_modules/polestar-c3/compat.js b/clone_modules/polestar-c3/compat.js new file mode 100644 index 0000000..59fe63c --- /dev/null +++ b/clone_modules/polestar-c3/compat.js @@ -0,0 +1,245 @@ +'use strict'; + +/** + * Drop-in replacement for the legacy clone_modules/polestar.js/polestar.js + * client. Exposes the same method names and object shapes so drivers/vehicle + * can swap the require with minimal diff, while sourcing data from the C3 + * gRPC backend. + * + * getBattery() → { batteryChargeLevelPercentage, chargingStatus, + * estimatedChargingTimeToFullMinutes, + * estimatedDistanceToEmptyKm/Miles, + * chargingCurrentAmps, chargingPowerWatts, + * chargingVoltageVolts, totalEnergyConsumedKwh, + * chargingType, chargerConnectionStatus } + * getOdometer() → { odometerMeters, tripMeterManualKm, tripMeterAutomaticKm } + * getHealthData() → { serviceWarning, daysToService, distanceToServiceKm, + * tyrePressures: { fl/fr/rl/rr }, anyTyreWarning, + * lowVoltageBatteryWarning } + */ + +const { PolestarC3 } = require('./client'); +const { ChargingStatus } = require('./messages'); + +// Throttle — the C3 servers return the latest cached value; we don't want to +// hammer them with one call per field from device.js. +const CACHE_MS = 60_000; + +function toNumber(v) { + if (v === null || v === undefined) return 0; + if (typeof v === 'bigint') return Number(v); + return Number(v); +} + +class PolestarCompat { + constructor(email, password) { + this._client = new PolestarC3(email, password); + this._vehicles = null; + this._batteryCache = { at: 0, data: null }; + } + + async login() { + await this._client.login(); + this._vehicles = await this._client.listVehicles(); + return true; + } + + async getVehicles() { + if (!this._vehicles) this._vehicles = await this._client.listVehicles(); + // Preserve the old shape used by drivers/vehicle/driver.js:94 so pairing + // keeps working: it reads .vin, .registrationNo, .content.model.name, + // .modelYear, .internalVehicleIdentifier, .content.images.studio.url, + // .deliveryDate, .hasPerformancePackage. The app-backend GraphQL query + // in discovery.js only supplies a subset; the missing keys resolve to + // undefined and the driver handles that (with `||` fallbacks). + return this._vehicles; + } + + async setVehicle(vin) { + if (!this._vehicles) this._vehicles = await this._client.listVehicles(); + const match = vin + ? this._vehicles.find((v) => v.vin === vin) + : this._vehicles[0]; + if (!match) throw new Error('Vehicle not found'); + await this._client.setVehicle(match.vin); + return { + vin: match.vin, + id: match.internalVehicleIdentifier, + }; + } + + _mapChargingStatus(code) { + const label = ChargingStatus[code] || 'UNSPECIFIED'; + return `CHARGING_STATUS_${label}`; + } + + async _fetchBattery() { + const now = Date.now(); + if (this._batteryCache.data && now - this._batteryCache.at < CACHE_MS) { + return this._batteryCache.data; + } + const resp = await this._client.getLatestBattery(); + this._batteryCache = { at: now, data: resp }; + return resp; + } + + async getBattery() { + const resp = await this._fetchBattery(); + const b = resp.battery || {}; + const whTotal = toNumber(b.total_consumption_wh); + return { + batteryChargeLevelPercentage: toNumber(b.charge_level), + chargingStatus: this._mapChargingStatus(b.charging_status), + estimatedChargingTimeToFullMinutes: toNumber(b.time_to_full), + estimatedDistanceToEmptyKm: toNumber(b.range_km), + estimatedDistanceToEmptyMiles: toNumber(b.range_miles), + chargingCurrentAmps: toNumber(b.current_amps), + chargingPowerWatts: toNumber(b.power_watts), + chargingVoltageVolts: toNumber(b.voltage_volts), + totalEnergyConsumedKwh: whTotal / 1000, + chargingTypeLabel: b.charging_type_label || null, + chargerConnectionStatusLabel: b.charger_connection_status_label || null, + chargerPowerStatus: toNumber(b.charger_power_status), + timestamp: b.timestamp || null, + }; + } + + async getOdometer() { + const resp = await this._client.getLatestOdometer(); + const o = resp.odometer || {}; + return { + odometerMeters: toNumber(o.odometer_meters), + tripMeterManualKm: toNumber(o.trip_meter_manual_km), + tripMeterAutomaticKm: toNumber(o.trip_meter_automatic_km), + }; + } + + async getHealthData() { + const resp = await this._client.getLatestHealth(); + const h = resp.health; + if (!h) return null; + + const tyre = { + frontLeftKpa: toNumber(h.front_left_tyre_pressure_kpa), + frontRightKpa: toNumber(h.front_right_tyre_pressure_kpa), + rearLeftKpa: toNumber(h.rear_left_tyre_pressure_kpa), + rearRightKpa: toNumber(h.rear_right_tyre_pressure_kpa), + }; + const tyreWarns = [ + h.front_left_tyre_pressure_warning, + h.front_right_tyre_pressure_warning, + h.rear_left_tyre_pressure_warning, + h.rear_right_tyre_pressure_warning, + ]; + const anyTyreWarning = tyreWarns.some((w) => w !== undefined && w !== 0 && w !== 1); + + return { + serviceWarning: `SERVICE_WARNING_${h.service_warning_label || 'UNSPECIFIED'}`, + daysToService: toNumber(h.days_to_service), + distanceToServiceKm: toNumber(h.distance_to_service_km), + tyrePressures: tyre, + anyTyreWarning, + lowVoltageBatteryWarningLevel: toNumber(h.low_voltage_battery_warning), + timestamp: h.timestamp || null, + }; + } + + async getExterior() { + const resp = await this._client.getLatestExterior(); + const e = resp.exterior; + if (!e) return null; + const isOpen = (v) => v === 1 || v === 3; // OPEN or AJAR + return { + isLocked: e.central_lock_label === 'LOCKED', + lockStatusLabel: e.central_lock_label, + doors: { + frontLeftOpen: isOpen(e.door_front_left), + frontRightOpen: isOpen(e.door_front_right), + rearLeftOpen: isOpen(e.door_rear_left), + rearRightOpen: isOpen(e.door_rear_right), + }, + windows: { + frontLeftOpen: isOpen(e.window_front_left), + frontRightOpen: isOpen(e.window_front_right), + rearLeftOpen: isOpen(e.window_rear_left), + rearRightOpen: isOpen(e.window_rear_right), + anyOpen: [e.window_front_left, e.window_front_right, e.window_rear_left, e.window_rear_right].some(isOpen), + }, + hoodOpen: isOpen(e.hood), + tailgateOpen: isOpen(e.tailgate), + tankLidOpen: isOpen(e.tank_lid), + sunroofOpen: isOpen(e.sunroof), + anyDoorOpen: [e.door_front_left, e.door_front_right, e.door_rear_left, e.door_rear_right].some(isOpen), + }; + } + + async getClimate() { + const resp = await this._client.getLatestClimate(); + const c = resp.climate; + if (!c) return null; + const active = c.running_status_label === 'ACTIVE'; + // Temperatures are raw ints; Polestar typically uses tenths-of-celsius, + // but we don't divide here — device.js decides based on plausibility. + return { + isActive: active, + runningStatusLabel: c.running_status_label, + requestTypeLabel: c.request_type_label, + timeRemainingMinutes: c.time_remaining || 0, + ventilationOnly: !!c.ventilation_only, + currentTempRaw: c.current_temp, + requestedTempRaw: c.requested_temp, + }; + } + + // --- Write commands (C3-only; legacy client does not implement these) --- + + chargeStart() { return this._client.chargeStart(); } + chargeStop() { return this._client.chargeStop(); } + getTargetSoc() { return this._client.getTargetSoc(); } + setTargetSoc(level, settingType) { return this._client.setTargetSoc(level, settingType); } + getAmpLimit() { return this._client.getAmpLimit(); } + setAmpLimit(amperage) { return this._client.setAmpLimit(amperage); } + + lock() { return this._client.lock(); } + unlock() { return this._client.unlock(); } + unlockTrunk() { return this._client.unlockTrunk(); } + honkFlash(opts) { return this._client.honkFlash(opts); } + climateStart(opts) { return this._client.climateStart(opts); } + climateStop() { return this._client.climateStop(); } + windowsOpen() { return this._client.windowsOpen(); } + windowsClose() { return this._client.windowsClose(); } + + async getOtaStatus() { + const info = await this._client.getOtaSoftwareInfo(); + if (!info) return null; + return { + updateAvailable: !!info.update_available, + stateLabel: info.state_label || 'UNKNOWN', + state: typeof info.state === 'number' ? info.state : 0, + newVersion: info.new_sw_version || null, + softwareId: info.software_id || null, + name: (info.description && info.description.name) || null, + shortDesc: (info.description && info.description.short_desc) || null, + scheduledAt: info.schedule_info && info.schedule_info.scheduled_at + ? Number(info.schedule_info.scheduled_at.seconds || 0) + : null, + }; + } + + async getLocation({ parked = false } = {}) { + const loc = parked + ? await this._client.getLastParkedLocation() + : await this._client.getLastKnownLocation(); + if (!loc) return null; + return { + latitude: Number(loc.latitude), + longitude: Number(loc.longitude), + timestamp: typeof loc.timestamp === 'bigint' ? Number(loc.timestamp) : (loc.timestamp || null), + }; + } + + getAccessToken() { return this._client._auth.accessToken; } + getVehicleVin() { return this._client._vin; } +} + +module.exports = PolestarCompat; diff --git a/clone_modules/polestar-c3/discovery.js b/clone_modules/polestar-c3/discovery.js new file mode 100644 index 0000000..32c81ce --- /dev/null +++ b/clone_modules/polestar-c3/discovery.js @@ -0,0 +1,73 @@ +'use strict'; + +const axios = require('axios'); +const { randomUUID } = require('crypto'); + +const C3_DISCOVERY_URL = 'https://cnepmob.volvocars.com/'; +const C3_ACCEPT_HEADER = 'application/volvo.cloud.cnepmob.v1+json'; + +const APP_BACKEND_GRAPHQL_URL = 'https://pc-api.polestar.com/eu-north-1/app-backend/api/graphql'; +const APP_BACKEND_ACCEPT = 'multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json'; +const APP_USER_AGENT = 'PolestarApp/5.5.0b1102 Android/14'; +const APP_FORCE_UPDATE_VERSION = '5.5.0'; +const APP_LOCALE = 'SE'; + +const GET_VEHICLES_QUERY = ` +query GetVDMSCars { + vdms { + getVehiclesInformation { + vin + internalVehicleIdentifier + registrationNo + modelYear + content { model { name } } + } + } +} +`; + +async function discoverC3Endpoint(accessToken) { + const r = await axios.get(C3_DISCOVERY_URL, { + headers: { + authorization: `Bearer ${accessToken}`, + accept: C3_ACCEPT_HEADER, + }, + timeout: 30000, + validateStatus: () => true, + }); + if (r.status !== 200) throw new Error(`C3 discovery failed: ${r.status}`); + const c3 = r.data.c3 || {}; + if (!c3.grpcHost) throw new Error('C3 discovery response missing grpcHost'); + return { + host: c3.grpcHost, + port: Number(c3.grpcPort || 443), + keepAliveTime: c3.grpcKeepAliveTime || null, + }; +} + +async function getVehicles(accessToken) { + const r = await axios.post(APP_BACKEND_GRAPHQL_URL, { + operationName: 'GetVDMSCars', + variables: {}, + query: GET_VEHICLES_QUERY, + extensions: { clientLibrary: { name: 'apollo-kotlin', version: '4.4.1' } }, + }, { + headers: { + 'user-agent': APP_USER_AGENT, + 'x-polestar-force-update-version': APP_FORCE_UPDATE_VERSION, + 'x-polestar-locale': APP_LOCALE, + 'x-polestarid-authorization': `Bearer ${accessToken}`, + 'x-apollo-operation-name': 'GetVDMSCars', + 'x-apollo-request-uuid': randomUUID(), + accept: APP_BACKEND_ACCEPT, + 'content-type': 'application/json', + }, + timeout: 30000, + validateStatus: () => true, + }); + if (r.status !== 200) throw new Error(`Vehicle list failed: ${r.status} ${JSON.stringify(r.data)}`); + const cars = (((r.data || {}).data || {}).vdms || {}).getVehiclesInformation || []; + return cars; +} + +module.exports = { discoverC3Endpoint, getVehicles }; diff --git a/clone_modules/polestar-c3/grpc.js b/clone_modules/polestar-c3/grpc.js new file mode 100644 index 0000000..3b6ba25 --- /dev/null +++ b/clone_modules/polestar-c3/grpc.js @@ -0,0 +1,209 @@ +'use strict'; + +const http2 = require('http2'); + +const USER_AGENT = 'grpc-java-okhttp/1.68.2'; + +function frameMessage(payload) { + const frame = Buffer.alloc(5 + payload.length); + frame[0] = 0; // compression flag: none + frame.writeUInt32BE(payload.length, 1); + payload.copy(frame, 5); + return frame; +} + +function parseFrames(buf) { + const messages = []; + let pos = 0; + while (pos + 5 <= buf.length) { + const compressed = buf[pos]; + const len = buf.readUInt32BE(pos + 1); + if (pos + 5 + len > buf.length) break; + if (compressed !== 0) throw new Error('Compressed gRPC frames not supported'); + messages.push(buf.slice(pos + 5, pos + 5 + len)); + pos += 5 + len; + } + return messages; +} + +function connect(host, port) { + return http2.connect(`https://${host}:${port || 443}`, { + settings: { enablePush: false }, + ALPNProtocols: ['h2'], + }); +} + +const GRPC_STATUS_NAMES = { + 0: 'OK', 1: 'CANCELLED', 2: 'UNKNOWN', 3: 'INVALID_ARGUMENT', 4: 'DEADLINE_EXCEEDED', + 5: 'NOT_FOUND', 6: 'ALREADY_EXISTS', 7: 'PERMISSION_DENIED', 8: 'RESOURCE_EXHAUSTED', + 9: 'FAILED_PRECONDITION', 10: 'ABORTED', 11: 'OUT_OF_RANGE', 12: 'UNIMPLEMENTED', + 13: 'INTERNAL', 14: 'UNAVAILABLE', 15: 'DATA_LOSS', 16: 'UNAUTHENTICATED', +}; + +function sanitizeHeaders(h) { + const out = {}; + for (const [k, v] of Object.entries(h || {})) { + if (k === 'authorization') { out[k] = '[redacted]'; continue; } + out[k] = v; + } + return out; +} + +function unaryUnary(session, method, requestBytes, metadata = {}, { timeoutMs = 30000, debug = false } = {}) { + return new Promise((resolve, reject) => { + const headers = { + ':method': 'POST', + ':path': method, + 'content-type': 'application/grpc', + te: 'trailers', + 'grpc-accept-encoding': 'identity,gzip', + 'grpc-encoding': 'identity', + 'user-agent': USER_AGENT, + ...metadata, + }; + + const req = session.request(headers, { endStream: false }); + const chunks = []; + let respHeaders = null; + let trailers = null; + let timedOut = false; + let settled = false; + + const finish = (fn, arg) => { if (!settled) { settled = true; clearTimeout(timer); fn(arg); } }; + + const timer = setTimeout(() => { + timedOut = true; + try { req.close(http2.constants.NGHTTP2_CANCEL); } catch (_) {} + finish(reject, new Error(`gRPC ${method} timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + req.on('response', (h) => { + respHeaders = h; + if (debug) console.error('[grpc response headers]', sanitizeHeaders(h)); + const httpStatus = Number(h[':status']); + // Trailers-only response: grpc-status is in the response HEADERS frame + // (server sent END_STREAM on HEADERS, so 'trailers' event won't fire). + if (h['grpc-status'] !== undefined) { + const s = Number(h['grpc-status']); + if (s !== 0) { + const msg = h['grpc-message'] || GRPC_STATUS_NAMES[s] || 'unknown'; + finish(reject, new Error(`gRPC ${method} trailers-only: status=${s} (${GRPC_STATUS_NAMES[s] || '?'}) message="${msg}" http=${httpStatus}`)); + } + // s===0 is unusual for trailers-only but let 'end' handle it + } else if (httpStatus !== 200) { + finish(reject, new Error(`gRPC ${method} HTTP ${httpStatus}; headers=${JSON.stringify(sanitizeHeaders(h))}`)); + } + }); + + req.on('trailers', (t) => { trailers = t; if (debug) console.error('[grpc trailers]', t); }); + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + if (timedOut || settled) return; + const body = Buffer.concat(chunks); + const effective = trailers || respHeaders || {}; + const s = effective['grpc-status']; + if (s !== undefined && Number(s) !== 0) { + const msg = effective['grpc-message'] || GRPC_STATUS_NAMES[Number(s)] || 'unknown'; + finish(reject, new Error(`gRPC ${method} status=${s} (${GRPC_STATUS_NAMES[Number(s)] || '?'}) message="${msg}"`)); + return; + } + if (body.length === 0) { + const dump = JSON.stringify({ + respHeaders: sanitizeHeaders(respHeaders), + trailers, + }); + finish(reject, new Error(`gRPC ${method} returned empty body; ${dump}`)); + return; + } + const frames = parseFrames(body); + if (frames.length === 0) { + finish(reject, new Error(`gRPC ${method} body (${body.length}B) not parseable as gRPC frames; hex=${body.toString('hex').slice(0, 80)}…`)); + return; + } + finish(resolve, frames[0]); + }); + req.on('error', (err) => finish(reject, err)); + + req.end(frameMessage(requestBytes)); + }); +} + +function serverStreamFirst(session, method, requestBytes, metadata = {}, { timeoutMs = 20000, debug = false } = {}) { + return new Promise((resolve, reject) => { + const headers = { + ':method': 'POST', + ':path': method, + 'content-type': 'application/grpc', + te: 'trailers', + 'grpc-accept-encoding': 'identity,gzip', + 'grpc-encoding': 'identity', + 'user-agent': USER_AGENT, + ...metadata, + }; + + const req = session.request(headers, { endStream: false }); + let respHeaders = null; + let buffered = Buffer.alloc(0); + let settled = false; + let timedOut = false; + + const finish = (fn, arg) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { req.close(http2.constants.NGHTTP2_CANCEL); } catch (_) {} + fn(arg); + }; + + const timer = setTimeout(() => { + timedOut = true; + finish(reject, new Error(`gRPC ${method} timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + req.on('response', (h) => { + respHeaders = h; + if (debug) console.error('[grpc response headers]', sanitizeHeaders(h)); + if (h['grpc-status'] !== undefined) { + const s = Number(h['grpc-status']); + if (s !== 0) { + const msg = h['grpc-message'] || GRPC_STATUS_NAMES[s] || 'unknown'; + finish(reject, new Error(`gRPC ${method} trailers-only: status=${s} (${GRPC_STATUS_NAMES[s] || '?'}) message="${msg}"`)); + } + } else if (Number(h[':status']) !== 200) { + finish(reject, new Error(`gRPC ${method} HTTP ${h[':status']}`)); + } + }); + + req.on('data', (chunk) => { + if (settled) return; + buffered = Buffer.concat([buffered, chunk]); + // Try to parse the first complete frame. + if (buffered.length < 5) return; + const len = buffered.readUInt32BE(1); + if (buffered.length < 5 + len) return; + const frame = buffered.slice(5, 5 + len); + finish(resolve, frame); + }); + + req.on('trailers', (t) => { + if (settled) return; + if (debug) console.error('[grpc trailers]', t); + const s = t['grpc-status']; + if (s !== undefined && Number(s) !== 0) { + const msg = t['grpc-message'] || GRPC_STATUS_NAMES[Number(s)] || 'unknown'; + finish(reject, new Error(`gRPC ${method} status=${s} (${GRPC_STATUS_NAMES[Number(s)] || '?'}) message="${msg}"`)); + } + }); + + req.on('end', () => { + if (settled || timedOut) return; + finish(reject, new Error(`gRPC ${method} stream ended without any message frames`)); + }); + + req.on('error', (err) => finish(reject, err)); + + req.end(frameMessage(requestBytes)); + }); +} + +module.exports = { connect, unaryUnary, serverStreamFirst, frameMessage, parseFrames }; diff --git a/clone_modules/polestar-c3/messages.js b/clone_modules/polestar-c3/messages.js new file mode 100644 index 0000000..fb1c6f8 --- /dev/null +++ b/clone_modules/polestar-c3/messages.js @@ -0,0 +1,369 @@ +'use strict'; + +const TimestampSchema = { + seconds: { num: 1, type: 'int64' }, + nanos: { num: 2, type: 'int32' }, +}; + +const VehicleRequestSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, +}; + +const BatterySchema = { + timestamp: { num: 1, type: 'message', schema: TimestampSchema }, + charge_level: { num: 2, type: 'double' }, + avg_consumption: { num: 3, type: 'double' }, + range_km: { num: 4, type: 'double' }, + time_to_full: { num: 5, type: 'int64' }, + charger_connection_status: { num: 6, type: 'enum' }, + charging_status: { num: 7, type: 'enum' }, + range_miles: { num: 8, type: 'double' }, + time_to_target: { num: 9, type: 'int64' }, + power_watts: { num: 10, type: 'int64' }, + current_amps: { num: 11, type: 'int64' }, + avg_consumption_auto: { num: 12, type: 'double' }, + avg_consumption_since_charge: { num: 13, type: 'double' }, + total_consumption_wh: { num: 14, type: 'double' }, + total_consumption_wh_auto: { num: 15, type: 'double' }, + total_consumption_wh_since_charge: { num: 16, type: 'double' }, + charging_type: { num: 17, type: 'enum' }, + voltage_volts: { num: 18, type: 'int64' }, + time_to_min_soc: { num: 19, type: 'int64' }, + consumption_wh_manual: { num: 20, type: 'double' }, + consumption_wh_auto: { num: 21, type: 'double' }, + consumption_wh_since_charge: { num: 22, type: 'double' }, + consumption_pct_manual: { num: 23, type: 'double' }, + consumption_pct_auto: { num: 24, type: 'double' }, + consumption_pct_since_charge: { num: 25, type: 'double' }, + charger_power_status: { num: 26, type: 'enum' }, +}; + +const GetBatteryResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + battery: { num: 3, type: 'message', schema: BatterySchema }, +}; + +const ChargingStatus = { + 0: 'UNSPECIFIED', + 1: 'CHARGING', + 2: 'IDLE', + 3: 'SCHEDULED', + 4: 'DISCHARGING', + 5: 'ERROR', + 6: 'SMART_CHARGING', + 7: 'DONE', + 8: 'SMART_CHARGING_PAUSED', +}; + +const ChargerConnectionStatus = { + 0: 'UNSPECIFIED', + 1: 'CONNECTED', + 2: 'DISCONNECTED', + 3: 'FAULT', +}; + +const ChargingType = { + 0: 'UNSPECIFIED', + 1: 'NONE', + 2: 'AC', + 3: 'DC', + 4: 'WIRELESS', +}; + +const OdometerStatusSchema = { + timestamp: { num: 1, type: 'message', schema: TimestampSchema }, + odometer_meters: { num: 2, type: 'int64' }, + trip_meter_manual_km: { num: 3, type: 'double' }, + trip_meter_automatic_km: { num: 4, type: 'double' }, +}; + +const GetOdometerResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + odometer: { num: 3, type: 'message', schema: OdometerStatusSchema }, +}; + +// Subset of Health — we skip light-failure/turn-signal fields for now (40+ fields). +// The decoder silently ignores fields not listed, so adding more later is safe. +const HealthSchema = { + timestamp: { num: 1, type: 'message', schema: TimestampSchema }, + days_to_service: { num: 3, type: 'int64' }, + distance_to_service_km: { num: 4, type: 'int64' }, + service_warning: { num: 5, type: 'enum' }, + brake_fluid_level_warning: { num: 6, type: 'enum' }, + engine_coolant_level_warning: { num: 7, type: 'enum' }, + oil_level_warning: { num: 8, type: 'enum' }, + front_left_tyre_pressure_warning: { num: 9, type: 'enum' }, + front_right_tyre_pressure_warning: { num: 10, type: 'enum' }, + rear_left_tyre_pressure_warning: { num: 11, type: 'enum' }, + rear_right_tyre_pressure_warning: { num: 12, type: 'enum' }, + washer_fluid_level_warning: { num: 13, type: 'enum' }, + low_voltage_battery_warning: { num: 38, type: 'enum' }, + front_left_tyre_pressure_kpa: { num: 39, type: 'double' }, + front_right_tyre_pressure_kpa: { num: 40, type: 'double' }, + rear_left_tyre_pressure_kpa: { num: 41, type: 'double' }, + rear_right_tyre_pressure_kpa: { num: 42, type: 'double' }, +}; + +const GetHealthResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + health: { num: 3, type: 'message', schema: HealthSchema }, +}; + +const ServiceWarning = { + 0: 'UNSPECIFIED', + 1: 'NO_WARNING', + 2: 'UNKNOWN_WARNING', + 3: 'REGULAR_MAINTENANCE_ALMOST_TIME', + 4: 'ENGINE_HOURS_ALMOST_TIME', + 5: 'DISTANCE_DRIVEN_ALMOST_TIME', + 6: 'REGULAR_MAINTENANCE_TIME', + 7: 'ENGINE_HOURS_TIME', + 8: 'DISTANCE_DRIVEN_TIME', +}; + +const TyrePressureWarning = { + 0: 'UNSPECIFIED', + 1: 'NO_WARNING', + 2: 'VERY_LOW_PRESSURE', + 3: 'LOW_PRESSURE', + 4: 'HIGH_PRESSURE', +}; + +// -- Exterior (DigitalTwin flat-field format) -- +// Polestar 4 uses the flat-field variant: each closure has a single int at +// its own field number. 0=UNSPEC, 1=OPEN/UNLOCKED, 2=CLOSED/LOCKED, 3=AJAR. + +const OpenStatus = { + 0: 'UNSPECIFIED', + 1: 'OPEN', + 2: 'CLOSED', + 3: 'AJAR', +}; + +const LockStatus = { + 0: 'UNSPECIFIED', + 1: 'UNLOCKED', + 2: 'LOCKED', +}; + +const ExteriorDigitalTwinSchema = { + central_lock: { num: 2, type: 'int32' }, + door_front_left: { num: 3, type: 'int32' }, + door_front_right: { num: 4, type: 'int32' }, + door_rear_left: { num: 5, type: 'int32' }, + door_rear_right: { num: 6, type: 'int32' }, + window_front_left: { num: 7, type: 'int32' }, + window_front_right: { num: 8, type: 'int32' }, + window_rear_left: { num: 9, type: 'int32' }, + window_rear_right: { num: 10, type: 'int32' }, + hood: { num: 11, type: 'int32' }, + tailgate: { num: 12, type: 'int32' }, + tank_lid: { num: 13, type: 'int32' }, + sunroof: { num: 14, type: 'int32' }, + tailgate_lock: { num: 16, type: 'int32' }, +}; + +const GetExteriorResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + exterior: { num: 3, type: 'message', schema: ExteriorDigitalTwinSchema }, +}; + +// -- Climate / parking climatization (DigitalTwin flat-field format) -- + +const ClimatizationRunningStatus = { + 0: 'UNDEFINED', + 1: 'ACTIVE', // DT code 1 == Active + 2: 'IDLE', // DT code 2 == Idle + 3: 'START_ATTEMPT', // DT code 3 == StartAttempt +}; + +const ClimatizationRequestType = { + 0: 'UNDEFINED', + 1: 'NOW_FROM_HMI', + 2: 'NOW_FROM_REMOTE', + 3: 'TIMER', + 4: 'NO_REQUEST', +}; + +const ClimateDigitalTwinSchema = { + running_status: { num: 2, type: 'int32' }, // DT-mapped enum + time_remaining: { num: 3, type: 'int32' }, // minutes (max ~30 for parking climatization) + ventilation_only: { num: 6, type: 'int32' }, // truthy = VENTILATION_ONLY action + current_temp: { num: 7, type: 'float' }, // °C (wire FIXED32, decoded as float) + requested_temp: { num: 8, type: 'float' }, // target °C (wire FIXED32) + request_type: { num: 15, type: 'int32' }, +}; + +const GetClimateResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + climate: { num: 3, type: 'message', schema: ClimateDigitalTwinSchema }, +}; + +// -- Invocation service (remote commands: lock, unlock, honk/flash, climate, windows) -- +// All requests wrap an InvocationRequest { vin } at field 1. + +const InvocationRequestSchema = { + vin: { num: 1, type: 'string' }, +}; + +const CarLockRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, + lock_type: { num: 2, type: 'int32' }, // 0=LOCK, 1=LOCK_REDUCED_GUARD +}; + +const CarUnlockRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, + unlock_type: { num: 2, type: 'int32' }, // 0=full unlock, 1=trunk only +}; + +const HonkFlashRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, + honk_flash_type: { num: 2, type: 'int32' }, // 0=BOTH, 1=HONK, 2=FLASH +}; + +const ClimatizationStartRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, + start: { num: 2, type: 'bool' }, + compartment_temperature_celsius: { num: 3, type: 'float' }, + front_right_seat: { num: 4, type: 'int32' }, + front_left_seat: { num: 5, type: 'int32' }, + rear_right_seat: { num: 6, type: 'int32' }, + rear_left_seat: { num: 7, type: 'int32' }, + steering_wheel: { num: 8, type: 'int32' }, +}; + +const ClimatizationStopRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, +}; + +const WindowControlRequestSchema = { + request: { num: 1, type: 'message', schema: InvocationRequestSchema }, + windows_control: { num: 2, type: 'int32' }, // 0=UNSPEC, 1=OPEN_ALL, 2=CLOSE_ALL +}; + +// WindowControlType enum +const WindowControlType = { UNSPECIFIED: 0, OPEN_ALL: 1, CLOSE_ALL: 2 }; + +// -- OTA (software update) -- + +const SoftwareDescriptionSchema = { + name: { num: 1, type: 'string' }, + short_desc: { num: 2, type: 'string' }, + long_desc: { num: 3, type: 'string' }, +}; + +const ScheduleInfoSchema = { + scheduled_at: { num: 2, type: 'message', schema: TimestampSchema }, +}; + +const CarSoftwareInfoSchema = { + software_id: { num: 1, type: 'string' }, + description: { num: 2, type: 'message', schema: SoftwareDescriptionSchema }, + qb_code: { num: 3, type: 'string' }, + state: { num: 4, type: 'int32' }, + new_sw_version: { num: 6, type: 'string' }, + schedule_info: { num: 8, type: 'message', schema: ScheduleInfoSchema }, + state_timestamp: { num: 10, type: 'message', schema: TimestampSchema }, +}; + +// GetSoftwareInfo response wraps a CarSoftwareInfo at field 1. +const GetSoftwareInfoResponseSchema = { + info: { num: 1, type: 'message', schema: CarSoftwareInfoSchema }, +}; + +const SoftwareState = { + 0: 'UNKNOWN', + 1: 'DOWNLOAD_READY', + 2: 'DOWNLOAD_STARTED', + 3: 'DOWNLOAD_COMPLETED', + 4: 'DOWNLOAD_FAILED', + 5: 'INSTALLATION_INITIATED', + 6: 'INSTALLATION_STARTED', + 7: 'INSTALLATION_ABORTED', + 8: 'INSTALLATION_FAILED', + 9: 'INSTALLATION_COMPLETED', + 10: 'INSTALLATION_DEFERRED', + 11: 'INSTALLATION_FAILED_CRITICAL', + 12: 'INSTALLATION_SCHEDULED', + 13: 'INSTALLATION_SCHEDULE_TRIGGERED', + 14: 'INSTALLATION_UNKNOWN', +}; + +// States that indicate an update is available or pending user action. +const OTA_AVAILABLE_STATES = new Set([1, 3, 10, 12]); // ready, completed-download, deferred, scheduled + +const InvocationResponseSchema = { + id: { num: 1, type: 'string' }, + vin: { num: 2, type: 'string' }, + status: { num: 3, type: 'int32' }, + message: { num: 4, type: 'string' }, + timestamp: { num: 5, type: 'int64' }, +}; + +const InvocationResponseEnvelopeSchema = { + response: { num: 1, type: 'message', schema: InvocationResponseSchema }, +}; + +// InvocationStatus enum (0=UNKNOWN_ERROR, 1=SENT, 2=CAR_OFFLINE, 4=DELIVERED, +// 5=DELIVERY_TIMEOUT, 6=SUCCESS, 7=RESPONSE_TIMEOUT, 8=UNKNOWN_CAR_ERROR, +// 9=NOT_ALLOWED_PRIVACY_ENABLED, 10=NOT_ALLOWED_WRONG_USAGE_MODE, +// 11=INVOCATION_SPECIFIC_ERROR, 12=NOT_ALLOWED_CONFLICTING_INVOCATION) +const InvocationStatus = { + 0: 'UNKNOWN_ERROR', 1: 'SENT', 2: 'CAR_OFFLINE', + 4: 'DELIVERED', 5: 'DELIVERY_TIMEOUT', 6: 'SUCCESS', + 7: 'RESPONSE_TIMEOUT', 8: 'UNKNOWN_CAR_ERROR', + 9: 'NOT_ALLOWED_PRIVACY_ENABLED', 10: 'NOT_ALLOWED_WRONG_USAGE_MODE', + 11: 'INVOCATION_SPECIFIC_ERROR', 12: 'NOT_ALLOWED_CONFLICTING_INVOCATION', +}; + +// HonkFlashAction: 0=HONK_AND_FLASH, 1=HONK, 2=FLASH +const HonkFlashAction = { HONK_AND_FLASH: 0, HONK: 1, FLASH: 2 }; + +// HeatingIntensity: 0=UNSPECIFIED, 1=OFF, 2=LEVEL1, 3=LEVEL2, 4=LEVEL3 +const HeatingIntensity = { UNSPECIFIED: 0, OFF: 1, LEVEL1: 2, LEVEL2: 3, LEVEL3: 4 }; + +module.exports = { + TimestampSchema, + VehicleRequestSchema, + BatterySchema, + GetBatteryResponseSchema, + OdometerStatusSchema, + GetOdometerResponseSchema, + HealthSchema, + GetHealthResponseSchema, + ExteriorDigitalTwinSchema, + GetExteriorResponseSchema, + ClimateDigitalTwinSchema, + GetClimateResponseSchema, + ChargingStatus, + ChargerConnectionStatus, + ChargingType, + ServiceWarning, + TyrePressureWarning, + OpenStatus, + LockStatus, + ClimatizationRunningStatus, + ClimatizationRequestType, + InvocationRequestSchema, + CarLockRequestSchema, + CarUnlockRequestSchema, + HonkFlashRequestSchema, + ClimatizationStartRequestSchema, + ClimatizationStopRequestSchema, + InvocationResponseEnvelopeSchema, + InvocationStatus, + HonkFlashAction, + HeatingIntensity, + WindowControlRequestSchema, + WindowControlType, + GetSoftwareInfoResponseSchema, + CarSoftwareInfoSchema, + SoftwareState, + OTA_AVAILABLE_STATES, +}; diff --git a/clone_modules/polestar-c3/package.json b/clone_modules/polestar-c3/package.json new file mode 100644 index 0000000..7acd5e2 --- /dev/null +++ b/clone_modules/polestar-c3/package.json @@ -0,0 +1,7 @@ +{ + "name": "polestar-c3", + "version": "0.0.1", + "private": true, + "main": "client.js", + "description": "C3 gRPC client for Polestar (Fase 0 PoC scaffold)" +} diff --git a/clone_modules/polestar.js b/clone_modules/polestar.js new file mode 160000 index 0000000..75dcf9a --- /dev/null +++ b/clone_modules/polestar.js @@ -0,0 +1 @@ +Subproject commit 75dcf9a69423fdb3d8902a468321116a9d144d5b diff --git a/debug-getvehicles.js b/debug-getvehicles.js new file mode 100644 index 0000000..8885e7f --- /dev/null +++ b/debug-getvehicles.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +'use strict'; + +const Polestar = require('./clone_modules/polestar.js/polestar.js'); +const axios = require('axios'); + +// Get credentials from command line arguments +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node debug-getvehicles.js '); + process.exit(1); +} + +const email = args[0]; +const password = args[1]; + +async function debugGetVehicles() { + console.log('\n=== Debugging getVehicles() API Call ===\n'); + + try { + // Initialize and login + console.log('Logging in...'); + const polestar = new Polestar(email, password); + await polestar.login(); + console.log('✓ Login successful!\n'); + + // Extract token using the new public method + console.log('Extracting access token...'); + const token = polestar.getAccessToken(); + + if (!token) { + console.error('❌ Could not extract access token'); + process.exit(1); + } + + console.log('✓ Access token obtained\n'); + + // Make raw API call to getConsumerCarsV2 + console.log('Making raw API call to getConsumerCarsV2...\n'); + + const query = `query getCars { + getConsumerCarsV2 { + vin + internalVehicleIdentifier + modelYear + content { + model { + code + name + __typename + } + images { + studio { + url + angles + __typename + } + __typename + } + __typename + } + hasPerformancePackage + registrationNo + deliveryDate + currentPlannedDeliveryDate + __typename + } +}`; + + try { + const response = await axios.post( + 'https://pc-api.polestar.com/eu-north-1/mystar-v2/', + { + query: query, + operationName: 'getCars', + variables: {} + }, + { + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'pragma': 'no-cache' + } + } + ); + + console.log('Raw API Response:'); + console.log(JSON.stringify(response.data, null, 2)); + + // Check for errors + if (response.data.errors) { + console.log('\n❌ GraphQL Errors Found:'); + response.data.errors.forEach((err, idx) => { + console.log(`\nError ${idx + 1}:`); + console.log(` Message: ${err.message}`); + if (err.path) { + console.log(` Path: ${err.path.join('.')}`); + } + if (err.locations) { + console.log(` Location: line ${err.locations[0].line}, column ${err.locations[0].column}`); + } + }); + } + + // Check for data + if (response.data.data) { + console.log('\n✅ Data Found:'); + if (response.data.data.getConsumerCarsV2) { + console.log(` Found ${response.data.data.getConsumerCarsV2.length} vehicle(s)`); + console.log('\nVehicle Details:'); + response.data.data.getConsumerCarsV2.forEach((vehicle, idx) => { + console.log(`\nVehicle ${idx + 1}:`); + console.log(` VIN: ${vehicle.vin}`); + console.log(` Model: ${vehicle.content?.model?.name || 'N/A'}`); + console.log(` Registration: ${vehicle.registrationNo || 'N/A'}`); + console.log(` Model Year: ${vehicle.modelYear || 'N/A'}`); + }); + } else { + console.log(' getConsumerCarsV2 field is null or missing'); + } + } else { + console.log('\n⚠️ No data field in response'); + } + + } catch (error) { + console.error('\n❌ API Request Failed:'); + console.error(' Error:', error.message); + if (error.response) { + console.error(' Status:', error.response.status); + console.error(' Response:', JSON.stringify(error.response.data, null, 2)); + } + } + + // Now test with the library method + console.log('\n\n=== Testing library getVehicles() method ===\n'); + try { + const vehicles = await polestar.getVehicles(); + console.log('✓ getVehicles() succeeded!'); + console.log(` Found ${vehicles.length} vehicle(s)`); + } catch (error) { + console.error('❌ getVehicles() failed:'); + console.error(' ', error.message); + } + + } catch (error) { + console.error('\n\x1b[31mUnexpected Error:\x1b[0m', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +debugGetVehicles(); diff --git a/discover-api.js b/discover-api.js new file mode 100644 index 0000000..d054bc8 --- /dev/null +++ b/discover-api.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +'use strict'; + +const Polestar = require('./clone_modules/polestar.js/polestar.js'); +const fs = require('fs'); + +// Get credentials from command line arguments +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node discover-api.js [output-file.json]'); + process.exit(1); +} + +const email = args[0]; +const password = args[1]; +const outputFile = args[2] || null; + +async function discoverAPI() { + console.log('\n=== Polestar API Discovery Tool ===\n'); + + try { + // Initialize and login + console.log('Logging in...'); + const polestar = new Polestar(email, password); + await polestar.login(); + console.log('✓ Login successful!\n'); + + // Get full schema + console.log('Retrieving GraphQL schema...'); + const schema = await polestar.getGraphQLSchema(); + console.log('✓ Schema retrieved!\n'); + + // Debug: Check what we got + console.log('Debug - Schema structure:', JSON.stringify(schema, null, 2).substring(0, 500)); + console.log('\nDebug - Has data?', !!schema.data); + console.log('Debug - Has __schema?', schema.data && !!schema.data.__schema); + + if (!schema.data || !schema.data.__schema) { + console.log('\nFull schema response:'); + console.log(JSON.stringify(schema, null, 2)); + throw new Error('Schema introspection is not supported or disabled on this API'); + } + + // Get available queries + console.log('\n=== AVAILABLE QUERIES ===\n'); + const queries = await polestar.getAvailableQueries(); + + queries.forEach(query => { + console.log(`\x1b[33m${query.name}\x1b[0m: ${query.returnType}`); + if (query.description) { + console.log(` Description: ${query.description}`); + } + if (query.args && query.args.length > 0) { + console.log(` Arguments:`); + query.args.forEach(arg => { + console.log(` - ${arg.name}: ${arg.type}`); + if (arg.description) { + console.log(` ${arg.description}`); + } + }); + } + console.log(''); + }); + + // Get available mutations + console.log('\n=== AVAILABLE MUTATIONS ===\n'); + const mutations = await polestar.getAvailableMutations(); + + if (mutations && mutations.length > 0) { + mutations.forEach(mutation => { + console.log(`\x1b[33m${mutation.name}\x1b[0m: ${mutation.returnType}`); + if (mutation.description) { + console.log(` Description: ${mutation.description}`); + } + if (mutation.args && mutation.args.length > 0) { + console.log(` Arguments:`); + mutation.args.forEach(arg => { + console.log(` - ${arg.name}: ${arg.type}`); + if (arg.description) { + console.log(` ${arg.description}`); + } + }); + } + console.log(''); + }); + } else { + console.log('No mutations available\n'); + } + + // Get details for specific types of interest + console.log('\n=== DETAILED TYPE INFORMATION ===\n'); + + // Get all custom types (exclude built-in GraphQL types) + const customTypes = schema.data.__schema.types.filter(type => + !type.name.startsWith('__') && + !['String', 'Int', 'Float', 'Boolean', 'ID'].includes(type.name) && + type.kind === 'OBJECT' + ); + + console.log(`\nFound ${customTypes.length} custom types. Showing types with "Car", "Battery", "Telematic", or "Health" in the name:\n`); + + const relevantTypes = customTypes.filter(type => + type.name.toLowerCase().includes('car') || + type.name.toLowerCase().includes('battery') || + type.name.toLowerCase().includes('telematic') || + type.name.toLowerCase().includes('health') || + type.name.toLowerCase().includes('odometer') + ); + + for (const type of relevantTypes) { + const details = await polestar.getTypeDetails(type.name); + console.log(`\x1b[36m${details.name}\x1b[0m (${details.kind})`); + if (details.description) { + console.log(` ${details.description}`); + } + if (details.fields && details.fields.length > 0) { + console.log(' Fields:'); + details.fields.forEach(field => { + console.log(` - ${field.name}: ${field.type}`); + if (field.description) { + console.log(` ${field.description}`); + } + }); + } + console.log(''); + } + + // Save to file if requested + if (outputFile) { + console.log(`\n\nSaving full schema to ${outputFile}...`); + fs.writeFileSync(outputFile, JSON.stringify(schema, null, 2), 'utf8'); + console.log('✓ Schema saved successfully!\n'); + } + + // Summary + console.log('\n=== SUMMARY ==='); + console.log(`Total Queries: ${queries.length}`); + console.log(`Total Mutations: ${mutations.length}`); + console.log(`Total Types: ${schema.data.__schema.types.length}`); + console.log(`Custom Types: ${customTypes.length}`); + console.log(`Relevant Vehicle Types: ${relevantTypes.length}\n`); + + } catch (error) { + console.error('\n\x1b[31mError:\x1b[0m', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +discoverAPI(); diff --git a/drivers/polestar-2-csv/device.js b/drivers/polestar-2-csv/device.js index 8058652..c4ba469 100644 --- a/drivers/polestar-2-csv/device.js +++ b/drivers/polestar-2-csv/device.js @@ -12,7 +12,8 @@ class PolestarBetaDevice extends Device { this.homey.app.log(this.homey.__({ en: `${this.name} has been initialized`, - no: `${this.name} har blitt initialisert` + no: `${this.name} har blitt initialisert`, + nl: `${this.name} is geinitialiseerd`, }), this.name, 'DEBUG'); moment.locale(this.homey.i18n.getLanguage() == 'no' ? 'nb' : 'en'); @@ -103,9 +104,18 @@ class PolestarBetaDevice extends Device { const id = this.settings.webhook_id || null; const secret = this.settings.webhook_secret || null; const data = {}; + this.webhook = await this.homey.cloud.createWebhook(id, secret, data); + this.homey.app.log(this.homey.__({ + en: 'Initializing webhook with ' + id + }), this.name, 'DEBUG'); this.webhook.on('message', async args => { + this.homey.app.log(this.homey.__({ + en: 'Received webhook message for ' + this.name, + no: 'Mottok webhook data med kjøretøydata for ' + this.name + }), this.name, 'DEBUG', args.body); + const fields = ['ambientTemperature', 'batteryLevel', 'chargePortConnected', 'ignitionState', 'power', 'selectedGear', 'speed', 'stateOfCharge']; const isDataMissing = fields.some(field => args.body[field] === undefined || args.body[field] === null); const hasFields = ['drivingPoints']; @@ -211,13 +221,21 @@ class PolestarBetaDevice extends Device { dateString = this.homey.__({ "en": "Unknown", "no": "Ukjent" }); } - let totalEnergy = drivingData.reduce((acc, point) => acc + point.energy_delta, 0); + let totalEnergy = drivingData.reduce((acc, point) => acc + (point.energy_delta || 0), 0); let energyUnit = 'Wh'; if (totalEnergy > 1000) { totalEnergy /= 1000; // Wh -> kWh energyUnit = 'kWh'; } + // Car Stats Viewer doesn't guarantee alt/state_of_charge are + // populated on every driving point — guard with finite-checks + // so a missing field never crashes the trip-ended trigger. + const fmt = (v, dec, suffix) => + Number.isFinite(v) ? `${v.toFixed(dec)}${suffix}` : this.homey.__({ "en": "Unavailable", "no": "Utilgjengelig", "nl": "Niet beschikbaar" }); + const first = drivingData[0] || {}; + const last = drivingData[drivingData.length - 1] || {}; + const tripData = { tripFrom: addressFrom, tripTo: addressTo, @@ -225,12 +243,12 @@ class PolestarBetaDevice extends Device { dateString: dateString, timeStringStart: drivingPointStart.toLocaleString(this.locale, { timeZone: 'Europe/Oslo', hour: '2-digit', minute: '2-digit', second: '2-digit' }), timeStringEnd: drivingPointEnd.toLocaleString(this.locale, { timeZone: 'Europe/Oslo', hour: '2-digit', minute: '2-digit', second: '2-digit' }), - tripDuration: moment.duration(drivingData[0].driving_point_epoch_time - drivingData[drivingData.length - 1].driving_point_epoch_time).humanize(), - socStart: `${drivingData[0].state_of_charge * 100}%`, - socEnd: `${drivingData[drivingData.length - 1].state_of_charge * 100}%`, - energyUsed: `${totalEnergy.toFixed(2)} ${energyUnit}`, - altStart: `${drivingData[0].alt.toFixed(0)} m`, - altEnd: `${drivingData[drivingData.length - 1].alt.toFixed(0)} m`, + tripDuration: moment.duration(first.driving_point_epoch_time - last.driving_point_epoch_time).humanize(), + socStart: fmt((first.state_of_charge || 0) * 100, 0, '%'), + socEnd: fmt((last.state_of_charge || 0) * 100, 0, '%'), + energyUsed: fmt(totalEnergy, 2, ` ${energyUnit}`), + altStart: fmt(first.alt, 0, ' m'), + altEnd: fmt(last.alt, 0, ' m'), }; const encodedSlug = base64url.encode(this.slug); /*this.tripSummaryImage.setUrl(`${this.apiUrl}/tripSummary/${encodedSlug}?mapType=${this.settings.mapImageType}&theme=${this.settings.tripSummaryStyle}&lang=${this.locale}`); @@ -419,33 +437,42 @@ class PolestarBetaDevice extends Device { } const url = `https://nominatim.openstreetmap.org/reverse.php?lat=${lat}&lon=${lon}&zoom=18&format=jsonv2`; - const response = await axios.get(url); - if (response.data && response.data.address) { - //const address = response.data.features[0].properties.formatted; - //const address = `${response.data.address.road} ${response.data.address.house_number}, ${response.data.address.postcode} ${response.data.address.suburb}`; - const address = { - road: response.data.address.road || null, - house_number: response.data.address.house_number || null, - postcode: response.data.address.postcode || null, - city: response.data.address.suburb || null, - city_district: response.data.address.city_district || null, - county: response.data.address.county || null, - country: response.data.address.country || null, - } + try { + const response = await axios.get(url); + if (response.data && response.data.address) { + //const address = response.data.features[0].properties.formatted; + //const address = `${response.data.address.road} ${response.data.address.house_number}, ${response.data.address.postcode} ${response.data.address.suburb}`; + const address = { + road: response.data.address.road || null, + house_number: response.data.address.house_number || null, + postcode: response.data.address.postcode || null, + city: response.data.address.suburb || null, + city_district: response.data.address.city_district || null, + county: response.data.address.county || null, + country: response.data.address.country || null, + } - // Update the previous values with the current values - this.previousLat = lat; - this.previousLon = lon; - this.previousAddress = address; + // Update the previous values with the current values + this.previousLat = lat; + this.previousLon = lon; + this.previousAddress = address; - this.homey.app.log(this.homey.__({ - en: 'Using new address', - no: 'Bruker ny adresse' - }), this.name, 'DEBUG'); + this.homey.app.log(this.homey.__({ + en: 'Using new address', + no: 'Bruker ny adresse' + }), this.name, 'DEBUG'); - return address; + return address; + } + return null; + } catch (err) { + this.homey.app.log(this.homey.__({ + en: 'Reverse geocoding failed', + no: 'Omvendt geokoding feilet' + }), this.name, 'WARNING', err.message); + // Return previous address if available, otherwise null + return this.previousAddress || null; } - return null; } async onAdded() { diff --git a/drivers/polestar-2-csv/driver.compose.json b/drivers/polestar-2-csv/driver.compose.json index 129624d..37c5f20 100644 --- a/drivers/polestar-2-csv/driver.compose.json +++ b/drivers/polestar-2-csv/driver.compose.json @@ -1,9 +1,11 @@ { "name": { "en": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)", - "no": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)" + "no": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)", + "nl": "Polestar 2 (Car Stats Viewer ᴮᴱᵀᴬ)" }, "class": "sensor", + "deprecated": true, "capabilities": [ "measure_battery", "measure_polestarIgnitionState", diff --git a/drivers/polestar-2-csv/driver.flow.compose.json b/drivers/polestar-2-csv/driver.flow.compose.json index 0381d55..8b5e10b 100644 --- a/drivers/polestar-2-csv/driver.flow.compose.json +++ b/drivers/polestar-2-csv/driver.flow.compose.json @@ -1,24 +1,55 @@ { "triggers": [ + { + "id": "measure_polestarConnected_false", + "title": { + "en": "Car disconnected from a charger", + "no": "Bil frakoblet lader", + "nl": "Auto is niet meer verbonden met de lader" + }, + "hint": { + "en": "When the car detects it is no longer connected to a charge port", + "no": "Når bilen oppdager at ladepunktet er frakoblet", + "nl": "Als de auto detecteerd dat de laadpoort niet meer verbonden is met een lader" + }, + "$filter": "capabilities=measure_polestarConnected" + }, + { + "id": "measure_polestarConnected_true", + "title": { + "en": "Car connected to a charger", + "no": "Bil tilkoblet lader", + "nl": "Auto is verbonden met een lader" + }, + "hint": { + "en": "When the car detects a charger connected to a charge port", + "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet", + "nl": "Wanneer de auto detecteerd dat de laadpoort verbonden is met een lader" + }, + "$filter": "capabilities=measure_polestarConnected" + }, { "id": "chargingStarted", "title": { "en": "Charging started", - "no": "Lading startet" + "no": "Lading startet", + "nl": "Laden is gestart" } }, { "id": "chargingEnded", "title": { "en": "Charging ended", - "no": "Lading stoppet" + "no": "Lading stoppet", + "nl": "Laden is gestopt" } }, { "id": "tripEnded", "title": { "en": "Trip ended", - "no": "Reisen er over" + "no": "Reisen er over", + "nl": "De rit is geindigd" }, "tokens": [ { @@ -26,11 +57,13 @@ "name": "lastTrip", "title": { "en": "Your last trip", - "no": "Din siste reise" + "no": "Din siste reise", + "nl": "Uw recente rit" }, "example": { "en": "An image visualizing your last trip, combined with useful trip data", - "no": "Et bilde som viser din siste reise, kombinert med nyttige reise-data" + "no": "Et bilde som viser din siste reise, kombinert med nyttige reise-data", + "nl": "Een visuele weergave van uw recente trip, gecombineerd met nuttige rit info" } }, { @@ -38,11 +71,13 @@ "name": "tripInfo", "title": { "en": "Trip Info", - "no": "Reiseinfo" + "no": "Reiseinfo", + "nl": "Rit info" }, "example": { "en": "An image visualizing data about your last trip", - "no": "Et bilde som viser data om din siste reise" + "no": "Et bilde som viser data om din siste reise", + "nl": "Een afbeelding met uw recente rit weergegeven" } }, { @@ -50,11 +85,13 @@ "name": "tripScore", "title": { "en": "Trip Score", - "no": "Reise-score" + "no": "Reise-score", + "nl": "Rit score" }, "example": { "en": "An image visualizing how well your last trip was", - "no": "Et bilde som viser hvor bra din siste reise var" + "no": "Et bilde som viser hvor bra din siste reise var", + "nl": "Een afbeelding over hoe goed uw rit was" } }, { @@ -62,11 +99,13 @@ "name": "tripFrom", "title": { "en": "Trip From", - "no": "Reise fra" + "no": "Reise fra", + "nl": "Rit vanaf" }, "example": { "en": "Storgata 1, Oslo", - "no": "Storgata 1, Oslo" + "no": "Storgata 1, Oslo", + "nl": "Storgata 1, Oslo" } }, { @@ -74,11 +113,13 @@ "name": "tripTo", "title": { "en": "Trip To", - "no": "Reise til" + "no": "Reise til", + "nl": "Rit naar" }, "example": { "en": "Karl Johans gate 1, Oslo", - "no": "Karl Johans gate 1, Oslo" + "no": "Karl Johans gate 1, Oslo", + "nl": "Karl Johans gate 1, Oslo" } }, { @@ -86,11 +127,13 @@ "name": "totalDistance", "title": { "en": "Total Distance", - "no": "Total distanse" + "no": "Total distanse", + "nl": "Totale afstand" }, "example": { "en": "2.5 km", - "no": "2.5 km" + "no": "2.5 km", + "nl": "2.5 km" } }, { @@ -98,11 +141,13 @@ "name": "dateString", "title": { "en": "Date", - "no": "Dato" + "no": "Dato", + "nl": "Datum" }, "example": { "en": "31.12.2023", - "no": "31.12.2023" + "no": "31.12.2023", + "nl": "31.12.2023" } }, { @@ -110,11 +155,13 @@ "name": "timeStringStart", "title": { "en": "Start Time", - "no": "Starttid" + "no": "Starttid", + "nl": "Start tijd" }, "example": { "en": "10:00", - "no": "10:00" + "no": "10:00", + "nl": "10:00" } }, { @@ -122,11 +169,13 @@ "name": "timeStringEnd", "title": { "en": "End Time", - "no": "Sluttid" + "no": "Sluttid", + "nl": "Eind tijd" }, "example": { "en": "10:30", - "no": "10:30" + "no": "10:30", + "nl": "10:30" } }, { @@ -134,11 +183,13 @@ "name": "tripDuration", "title": { "en": "Trip Duration", - "no": "Reisens varighet" + "no": "Reisens varighet", + "nl": "Ritduur" }, "example": { "en": "2.5 hours", - "no": "2.5 timer" + "no": "2.5 timer", + "nl": "2.5 timer" } }, { @@ -146,11 +197,13 @@ "name": "socStart", "title": { "en": "Start State of Charge", - "no": "Starttilstand for lading" + "no": "Starttilstand for lading", + "nl": "Startniveau van batterijniveau" }, "example": { "en": "80%", - "no": "80%" + "no": "80%", + "nl": "80%" } }, { @@ -158,11 +211,13 @@ "name": "socEnd", "title": { "en": "End State of Charge", - "no": "Slutttilstand for lading" + "no": "Slutttilstand for lading", + "nl": "Eindniveau van batterijniveau" }, "example": { "en": "50%", - "no": "50%" + "no": "50%", + "nl": "50%" } }, { @@ -170,11 +225,13 @@ "name": "energyUsed", "title": { "en": "Energy Used", - "no": "Energi brukt" + "no": "Energi brukt", + "nl": "Verbruikte energy" }, "example": { "en": "12.41 kWh", - "no": "12.41 kWh" + "no": "12.41 kWh", + "nl": "12.41 kWh" } }, { @@ -182,11 +239,13 @@ "name": "altStart", "title": { "en": "Start Altitude", - "no": "Start høyde" + "no": "Start høyde", + "nl": "Start hoogte" }, "example": { "en": "20 m", - "no": "20 m" + "no": "20 m", + "nl": "20 m" } }, { @@ -194,11 +253,13 @@ "name": "altEnd", "title": { "en": "End Altitude", - "no": "Slutt høyde" + "no": "Slutt høyde", + "nl": "Eind hoogte" }, "example": { "en": "40 m", - "no": "40 m" + "no": "40 m", + "nl": "40 m" } } ] @@ -207,7 +268,8 @@ "id": "measure_polestarPower_changed", "title": { "en": "Power changed", - "no": "Strøm endret" + "no": "Strøm endret", + "nl": "Stroom verandert" }, "tokens": [ { @@ -215,11 +277,13 @@ "type": "number", "title": { "en": "Power", - "no": "Strøm" + "no": "Strøm", + "nl": "Stroom" }, "example": { "en": "12.41 kW", - "no": "12.41 kW" + "no": "12.41 kW", + "nl": "12.41 kW" } } ] diff --git a/drivers/polestar-2-csv/driver.js b/drivers/polestar-2-csv/driver.js index 191d2bf..eb27816 100644 --- a/drivers/polestar-2-csv/driver.js +++ b/drivers/polestar-2-csv/driver.js @@ -56,31 +56,31 @@ class PolestarBetaDriver extends Driver { } }); - try { - const registerPolestarUser = await axios.post(`https://homey.crdx.us/register/${args.slug}`, { - slug: args.slug, - homeyId: this.homeyId, - webhookId: webhook.id, - webhookSecret: webhook.secret, - webhookUrl: webhookUrl, - shortWebhookUrl: args.url_short, - }); - - if (registerPolestarUser.status === 200 && registerPolestarUser.data.success) { - this.homey.app.log(this.homey.__({ - en: 'Successfully registered user for webhook', - no: 'Klarte å registrere bruker for webhook' - }), 'Polestar Driver CSV ᴮᴱᵀᴬ', 'DEBUG', registerPolestarUser.data); - } - } catch (error) { - this.homey.app.log(this.homey.__({ - en: 'Failed to register user for webhook', - no: 'Klarte ikke å registrere bruker for webhook' - }), 'Polestar Driver CSV ᴮᴱᵀᴬ', 'ERROR', error.message); - return { success: false, error: error.message }; - } - - this.webhookUrl = webhookUrl; + // try { + // const registerPolestarUser = await axios.post(`https://homey.crdx.us/register/${args.slug}`, { + // slug: args.slug, + // homeyId: this.homeyId, + // webhookId: webhook.id, + // webhookSecret: webhook.secret, + // webhookUrl: webhookUrl, + // shortWebhookUrl: args.url_short, + // }); + + // if (registerPolestarUser.status === 200 && registerPolestarUser.data.success) { + // this.homey.app.log(this.homey.__({ + // en: 'Successfully registered user for webhook', + // no: 'Klarte å registrere bruker for webhook' + // }), 'Polestar Driver CSV ᴮᴱᵀᴬ', 'DEBUG', registerPolestarUser.data); + // } + // } catch (error) { + // this.homey.app.log(this.homey.__({ + // en: 'Failed to register user for webhook', + // no: 'Klarte ikke å registrere bruker for webhook' + // }), 'Polestar Driver CSV ᴮᴱᵀᴬ', 'ERROR', error.message); + // return { success: false, error: error.message }; + // } + + // this.webhookUrl = webhookUrl; this.homey.app.log(this.homey.__({ en: 'Webhook created', no: 'Webhook opprettet' }), 'Polestar Driver CSV ᴮᴱᵀᴬ', 'DEBUG'); return { success: true }; diff --git a/drivers/polestar-2-csv/driver.settings.compose.json b/drivers/polestar-2-csv/driver.settings.compose.json index 2f87d2c..383d984 100644 --- a/drivers/polestar-2-csv/driver.settings.compose.json +++ b/drivers/polestar-2-csv/driver.settings.compose.json @@ -2,196 +2,223 @@ { "type": "group", "label": { - "en": "General settings", - "no": "Generelle innstillinger" + "en": "General settings", + "no": "Generelle innstillinger", + "nl": "Algemene instellingen" }, "children": [ - { - "id": "endTripThreshold", - "type": "number", - "value": 10, - "label": { - "en": "End trip threshold", - "no": "Terskel for endt tur" - }, - "hint": { - "en": "The time in minutes after which a trip should be considered ended. (Optional)", - "no": "Terskel i minutter for når en tur skal anses som avsluttet. (Valgfritt)" - } + { + "id": "endTripThreshold", + "type": "number", + "value": 10, + "label": { + "en": "End trip threshold", + "no": "Terskel for endt tur", + "nl": "Einde rit drempel tijd" + }, + "hint": { + "en": "The time in minutes after which a trip should be considered ended. (Optional)", + "no": "Terskel i minutter for når en tur skal anses som avsluttet. (Valgfritt)", + "nl": "De tijd in minuten waarna de rit wordt beschouwd als geindigd. (Optioneel)" } + } ] - }, + }, { "type": "group", "label": { "en": "Trip summary settings", - "no": "Innstillinger for turoppsummering" + "no": "Innstillinger for turoppsummering", + "nl": "Rit samenvatting instellingen" }, "children": [ { - "id": "tripSummaryEnabled", - "type": "checkbox", - "value": true, - "label": { - "en": "Enable trip summary", - "no": "Aktiver turoppsummering" + "id": "tripSummaryEnabled", + "type": "checkbox", + "value": true, + "label": { + "en": "Enable trip summary", + "no": "Aktiver turoppsummering", + "nl": "Gebruik rit samenvatting" + }, + "hint": { + "en": "Enable or disable the trip summary. (Optional) NOTE: By enabling you accept that your trip data will be stored anonymously in the cloud. You can disable this at any time. By deleting the device, you will also permanently delete all your trip data. App restart is required after changing this setting.", + "no": "Aktiver eller deaktiver turoppsummeringen. (Valgfritt) MERK: Ved å aktivere godtar du at turoppsummeringen lagres anonymt i skyen. Du kan deaktivere dette når som helst. Ved å slette enheten vil du også slette all turoppsummeringsdata permanent. App restart er nødvendig etter endring av denne innstillingen.", + "nl": "Aan of uit zetten van de rit samenvatting. (Optioneel) OPMERKING: Door de rit samenvatting te activeren accepteert u dat de rit informatie annoniem wordt opgeslagen in de cloud. U kunt dit ten alle tijden weer uitzetten. Als u dit device weer verwijdert wordt ook de cloud data permanent verwijdert. De App moet opnieuw worden opgestart als u deze instelling aanpast." + } + }, + { + "id": "tripSummaryStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip summary style", + "no": "Turoppsummeringens stil", + "nl": "Rit samenvatting uiterlijk" + }, + "hint": { + "en": "This changes the style of the trip summary. (Optional)", + "no": "Dette endrer stilen til turoppsummeringen. (Valgfritt)", + "nl": "Dit past het uiterlijk van de rit samenvatting aan. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } }, - "hint": { - "en": "Enable or disable the trip summary. (Optional) NOTE: By enabling you accept that your trip data will be stored anonymously in the cloud. You can disable this at any time. By deleting the device, you will also permanently delete all your trip data. App restart is required after changing this setting.", - "no": "Aktiver eller deaktiver turoppsummeringen. (Valgfritt) MERK: Ved å aktivere godtar du at turoppsummeringen lagres anonymt i skyen. Du kan deaktivere dette når som helst. Ved å slette enheten vil du også slette all turoppsummeringsdata permanent. App restart er nødvendig etter endring av denne innstillingen." + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } } + ] }, { - "id": "tripSummaryStyle", - "type": "dropdown", - "value": "light", - "label": { - "en": "Trip summary style", - "no": "Turoppsummeringens stil" - }, - "hint": { - "en": "This changes the style of the trip summary. (Optional)", - "no": "Dette endrer stilen til turoppsummeringen. (Valgfritt)" + "id": "tripInfoStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip info style", + "no": "Turinfoens stil", + "nl": "Rit samenvatting stijl" + }, + "hint": { + "en": "This changes the style of the trip info. (Optional)", + "no": "Dette endrer stilen til turinfoen. (Valgfritt)", + "nl": "Dit verandert de stijl van de rit samenvatting. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } }, - "values": [ - { - "id": "light", - "label": { - "en": "Light", - "no": "Lys" - } - }, - { - "id": "dark", - "label": { - "en": "Dark", - "no": "Mørk" - } - } - ] + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } + } + ] }, { - "id": "tripInfoStyle", - "type": "dropdown", - "value": "light", - "label": { - "en": "Trip info style", - "no": "Turinfoens stil" - }, - "hint": { - "en": "This changes the style of the trip info. (Optional)", - "no": "Dette endrer stilen til turinfoen. (Valgfritt)" + "id": "tripScoreStyle", + "type": "dropdown", + "value": "light", + "label": { + "en": "Trip score style", + "no": "Kjørescorens stil", + "nl": "Rit score stijl" + }, + "hint": { + "en": "This changes the style of the trip score. (Optional)", + "no": "Dette endrer stilen til kjørescoren. (Valgfritt)", + "nl": "Dit verandert de stijl van de rit score. (Optioneel)" + }, + "values": [ + { + "id": "light", + "label": { + "en": "Light", + "no": "Lys", + "nl": "Ligt" + } }, - "values": [ - { - "id": "light", - "label": { - "en": "Light", - "no": "Lys" - } - }, - { - "id": "dark", - "label": { - "en": "Dark", - "no": "Mørk" - } - } - ] + { + "id": "dark", + "label": { + "en": "Dark", + "no": "Mørk", + "nl": "Donker" + } + } + ] }, { - "id": "tripScoreStyle", - "type": "dropdown", - "value": "light", - "label": { - "en": "Trip score style", - "no": "Kjørescorens stil" + "id": "mapImageType", + "type": "dropdown", + "value": "mapboxOutdoors", + "label": { + "en": "Map image type", + "no": "Kart-bildetype", + "nl": "Kaartweergave stijl" + }, + "hint": { + "en": "This changes the type of the map that is shown in the trip summary. (Optional)", + "no": "Dette endrer hvilket type kart som vises i turoppsummeringen. (Valgfritt)", + "nl": "Dit verandert de stijl van de kaart die bij de rit samenvatting wordt weergegeven. (Optioneel)" + }, + "values": [ + { + "id": "mapboxLight", + "label": { + "en": "Mapbox Light", + "no": "Mapbox Light", + "nl": "Mapbox Ligt" + } }, - "hint": { - "en": "This changes the style of the trip score. (Optional)", - "no": "Dette endrer stilen til kjørescoren. (Valgfritt)" + { + "id": "mapboxDark", + "label": { + "en": "Mapbox Dark", + "no": "Mapbox Dark", + "nl": "Mapbox Donker" + } }, - "values": [ - { - "id": "light", - "label": { - "en": "Light", - "no": "Lys" - } - }, - { - "id": "dark", - "label": { - "en": "Dark", - "no": "Mørk" - } - } - ] - }, - { - "id": "mapImageType", - "type": "dropdown", - "value": "mapboxOutdoors", - "label": { - "en": "Map image type", - "no": "Kart-bildetype" + { + "id": "mapboxStreets", + "label": { + "en": "Mapbox Streets", + "no": "Mapbox Streets", + "nl": "Mapbox Straten" + } }, - "hint": { - "en": "This changes the type of the map that is shown in the trip summary. (Optional)", - "no": "Dette endrer hvilket type kart som vises i turoppsummeringen. (Valgfritt)" + { + "id": "mapboxOutdoors", + "label": { + "en": "Mapbox Outdoors", + "no": "Mapbox Outdoors", + "nl": "Mapbox Buitenleven" + } }, - "values": [ - { - "id": "mapboxLight", - "label": { - "en": "Mapbox Light", - "no": "Mapbox Light" - } - }, - { - "id": "mapboxDark", - "label": { - "en": "Mapbox Dark", - "no": "Mapbox Dark" - } - }, - { - "id": "mapboxStreets", - "label": { - "en": "Mapbox Streets", - "no": "Mapbox Streets" - } - }, - { - "id": "mapboxOutdoors", - "label": { - "en": "Mapbox Outdoors", - "no": "Mapbox Outdoors" - } - }, - { - "id": "mapboxSatellite", - "label": { - "en": "Mapbox Satellite", - "no": "Mapbox Satellite" - } - }, - { - "id": "mapboxSatelliteStreets", - "label": { - "en": "Mapbox Satellite Streets", - "no": "Mapbox Satellite Streets" - } - } - ] + { + "id": "mapboxSatellite", + "label": { + "en": "Mapbox Satellite", + "no": "Mapbox Satellite", + "nl": "Mapbox Sateliet" + } + }, + { + "id": "mapboxSatelliteStreets", + "label": { + "en": "Mapbox Satellite Streets", + "no": "Mapbox Satellite Streets", + "nl": "Mapbox Sateliet Straten" + } + } + ] } - ] + ] }, { "type": "group", "label": { "en": "Webhook Settings", - "no": "Webhook innstillinger" + "no": "Webhook innstillinger", + "nl": "Webhook instellingen" }, "children": [ { @@ -199,11 +226,13 @@ "type": "text", "hint": { "en": "Do not change unless instructed by developer, or if you changed the webhook. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler, eller hvis du har endret webhooken. Denne innstillingen har ingen funksjon, og er kun til informasjon." + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler, eller hvis du har endret webhooken. Denne innstillingen har ingen funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen, of tenzij je de webhook hebt aangepast. Deze setting heeft geen functie, en is er ter informatie" }, "label": { "en": "Webhook URL", - "no": "Webhook URL" + "no": "Webhook URL", + "nl": "Webhook URL" } }, { @@ -211,11 +240,13 @@ "type": "text", "hint": { "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon." + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen. Deze setting heeft geen functie, en is er ter informatie" }, "label": { "en": "Webhook URL (short)", - "no": "Webhook URL (kort)" + "no": "Webhook URL (kort)", + "nl": "Webhook URL (kort)" } }, { @@ -223,11 +254,13 @@ "type": "label", "hint": { "en": "Do not change unless instructed by developer. This setting has no function, and is only for information.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon." + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler. Dette er ikke en funksjon, og er kun til informasjon.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen. Deze setting heeft geen functie, en is er ter informatie" }, "label": { "en": "Webhook slug", - "no": "Webhook slug" + "no": "Webhook slug", + "nl": "Webhook slug" } }, { @@ -235,11 +268,13 @@ "type": "label", "hint": { "en": "Do not change unless instructed by developer.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler." + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen." }, "label": { "en": "Webhook ID", - "no": "Webhook ID" + "no": "Webhook ID", + "nl": "Webhook ID" } }, { @@ -247,11 +282,13 @@ "type": "label", "hint": { "en": "Do not change unless instructed by developer.", - "no": "Ikke endre med mindre du har fått beskjed om det av utvikler." + "no": "Ikke endre med mindre du har fått beskjed om det av utvikler.", + "nl": "Niet aanpassingen tenzij door de developer opgedragen." }, "label": { "en": "Webhook secret", - "no": "Webhook secret" + "no": "Webhook secret", + "nl": "Webhook secret" } } ] diff --git a/drivers/polestar-2-csv/pair/start.html b/drivers/polestar-2-csv/pair/start.html index da0f8b7..e4da62c 100644 --- a/drivers/polestar-2-csv/pair/start.html +++ b/drivers/polestar-2-csv/pair/start.html @@ -11,9 +11,7 @@

Viktig:

Sørg for at "Car Stats Viewer"-appen er installert på din Polestar 2 for å bruke denne enheten. Denne appen er ikke tilgjengelig i Google Play Store for Polestar, grunnet strenge regler i Android Automotive OS.

-

For å laste ned appen, kan du bruke vår "internal test track". For å få tilgang, send en - e-post med forespørsel til polestar@coderax.dev. Inkluder din - Google Play Store e-postadresse. Når du har +

For å laste ned appen, kan du bruke vår "internal test track". Når du har fått bekreftet at du er lagt inn i "internal test track", klikk her for bli med i test track.

@@ -27,7 +25,7 @@

Viktig: - \ No newline at end of file diff --git a/drivers/vehicle/assets/battery-100.svg b/drivers/vehicle/assets/battery-100.svg new file mode 100644 index 0000000..c5f44e4 --- /dev/null +++ b/drivers/vehicle/assets/battery-100.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/drivers/vehicle/assets/battery-75.svg b/drivers/vehicle/assets/battery-75.svg new file mode 100644 index 0000000..131d099 --- /dev/null +++ b/drivers/vehicle/assets/battery-75.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/drivers/vehicle/assets/hvbattery.svg b/drivers/vehicle/assets/hvbattery.svg new file mode 100644 index 0000000..e4b0aac --- /dev/null +++ b/drivers/vehicle/assets/hvbattery.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/vehicle/assets/location.svg b/drivers/vehicle/assets/location.svg new file mode 100644 index 0000000..27901ee --- /dev/null +++ b/drivers/vehicle/assets/location.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/drivers/vehicle/assets/powerdc.svg b/drivers/vehicle/assets/powerdc.svg new file mode 100644 index 0000000..291da91 --- /dev/null +++ b/drivers/vehicle/assets/powerdc.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/drivers/vehicle/assets/tire-pressure.svg b/drivers/vehicle/assets/tire-pressure.svg new file mode 100644 index 0000000..7e7f6b7 --- /dev/null +++ b/drivers/vehicle/assets/tire-pressure.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/vehicle/assets/update.svg b/drivers/vehicle/assets/update.svg new file mode 100644 index 0000000..ffa235b --- /dev/null +++ b/drivers/vehicle/assets/update.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/vehicle/device.js b/drivers/vehicle/device.js index d192fe7..d7156fb 100644 --- a/drivers/vehicle/device.js +++ b/drivers/vehicle/device.js @@ -1,18 +1,107 @@ 'use strict'; const { Device } = require('homey'); -const Polestar = require('@andysmithfal/polestar.js'); +const LegacyPolestar = require('../../clone_modules/polestar.js'); +const PolestarC3Compat = require('../../clone_modules/polestar-c3/compat'); const HomeyCrypt = require('../../lib/homeycrypt') +const measureInterval = 60000; +const KM_TO_MILES = 0.621371; + +// Map feature-key → { capabilities: [...] } for auto-remove on UNIMPLEMENTED. +const OPTIONAL_FEATURES = { + amp_limit: { capabilities: ['target_polestarAmpLimit'] }, + target_soc: { capabilities: ['target_polestarChargeLimit'] }, + windows: { capabilities: ['button.windows_open', 'button.windows_close'] }, +}; + +function selectClient(homey) { + const legacy = homey.settings.get('c3_backend_disabled') === true; + return legacy ? LegacyPolestar : PolestarC3Compat; +} + +function isUnimplementedError(err) { + if (!err || !err.message) return false; + return /status=12\b|UNIMPLEMENTED|not supported/i.test(err.message); +} + +/** Convert a raw gRPC error into a message Homey can surface usefully. */ +function friendlyGrpcError(message, label) { + if (!message) return `${label} failed`; + const m = /status=(\d+)[^m]*message="([^"]*)"/.exec(message); + if (!m) return message; + const code = Number(m[1]); + const detail = m[2]; + if (code === 12) return `${label}: not supported for this vehicle (${detail})`; + if (code === 7) return `${label}: permission denied (${detail || 'VIN not linked to this account'})`; + if (code === 16) return `${label}: authentication expired — try again`; + if (code === 14) return `${label}: service temporarily unavailable`; + if (code === 4) return `${label}: command timed out`; + return `${label}: ${detail || 'error'} (code ${code})`; +} + var polestar = null; class PolestarVehicle extends Device { + + /** + * Check if the user has selected miles as the distance unit + * @returns {boolean} true if miles, false if km (default) + */ + usesMiles() { + return this.homey.settings.get('distance_unit') === 'miles'; + } + + /** + * Update capability units based on distance unit setting + */ + async updateCapabilityUnits() { + const unit = this.usesMiles() ? { en: 'mi' } : { en: 'km' }; + try { + await this.setCapabilityOptions('measure_vehicleOdometer', { units: unit }); + await this.setCapabilityOptions('measure_vehicleRange', { units: unit }); + await this.setCapabilityOptions('measure_vehicleDistanceTillService', { units: unit }); + this.homey.app.log(`Updated capability units to ${this.usesMiles() ? 'miles' : 'km'}`, 'PolestarVehicle', 'DEBUG'); + } catch (err) { + this.homey.app.log('Failed to update capability units', 'PolestarVehicle', 'ERROR', err); + } + } + + /** + * Attempt to re-login when session has expired + */ + async attemptReLogin() { + // Prevent multiple simultaneous re-login attempts + if (this._reLoginInProgress) { + this.homey.app.log('Re-login already in progress, skipping', 'PolestarVehicle', 'DEBUG'); + return; + } + + this._reLoginInProgress = true; + try { + let PolestarUser = this.homey.settings.get('user_email'); + let PolestarPwd = await HomeyCrypt.decrypt(this.homey.settings.get('user_password'), PolestarUser); + + const Client = selectClient(this.homey); + this.polestar = new Client(PolestarUser, PolestarPwd); + await this.polestar.login(); + await this.polestar.setVehicle(this.getData().vin); + + this.homey.app.log('Re-login successful', 'PolestarVehicle', 'DEBUG'); + } catch (err) { + this.homey.app.log('Re-login failed', 'PolestarVehicle', 'ERROR', err); + } finally { + this._reLoginInProgress = false; + } + } + async onInit() { if (this.polestar == null) { let PolestarUser = this.homey.settings.get('user_email'); try { let PolestarPwd = await HomeyCrypt.decrypt(this.homey.settings.get('user_password'), PolestarUser); - this.polestar = new Polestar(PolestarUser, PolestarPwd); + const Client = selectClient(this.homey); + this.polestar = new Client(PolestarUser, PolestarPwd); } catch (err) { this.homey.app.log('Could not decrypt using salt, network connection changed?', 'PolestarVehicle', 'ERROR', err); return; @@ -25,28 +114,127 @@ class PolestarVehicle extends Device { return; } } - + await this.fixCapabilities(); + await this.fixEnergy(); + await this.updateCapabilityUnits(); + this._registerWriteCapabilityListeners(); this.update_loop_timers(); + this.refreshChargingTargets(); // best-effort initial read + + // Listen for distance unit setting changes + this.homey.settings.on('set', async (key) => { + if (key === 'distance_unit') { + this.homey.app.log('Distance unit setting changed', 'PolestarVehicle', 'DEBUG'); + await this.updateCapabilityUnits(); + // Refresh values with new unit + await this.updateVehicleState(); + await this.updateHealthState(); + // Send specific event for widget to refresh + this.homey.api.realtime('distanceUnitChanged'); + } + }); - this.homey.app.log('PolestarVehicle has been initialized', 'PolestarVehicle'); + this.homey.app.log(this.homey.__({ + en: `${this.name} has been initialized`, + no: `${this.name} har blitt initialisert`, + nl: `${this.name} is geinitialiseerd`, + }), this.name, 'DEBUG'); } async update_loop_timers() { await this.updateVehicleState(); - let interval = 60000; this._timerTimers = this.homey.setInterval(async () => { await this.updateVehicleState(); - }, interval); + }, measureInterval); + + // Health, tyre pressures, current target SoC / amp limit: refresh every 15 min + // so externally-made changes (e.g. via the Polestar mobile app) show up in Homey + // within that window without hammering the backend. + const slowInterval = 15 * 60 * 1000; + await this._runSlowCycle(); + this._timerHealth = this.homey.setInterval(async () => { + await this._runSlowCycle(); + }, slowInterval); + } + + async _runSlowCycle() { + await this.updateHealthState(); + await this.refreshAmpLimit(); + await this.updateLocationState(); + await this.updateOtaState(); + } + + async updateOtaState() { + if (this._destroyed) return; + if (!this.polestar || typeof this.polestar.getOtaStatus !== 'function') return; + try { + const ota = await this.polestar.getOtaStatus(); + if (!ota) return; + await this.setCapabilityValue('alarm_polestarOtaAvailable', !!ota.updateAvailable); + // "UNKNOWN" (state=0) really means "no pending update info returned" — show a + // friendlier label so the tile doesn't imply the sensor is broken. + const stateText = (!ota.state && !ota.newVersion) ? 'No pending update' : (ota.stateLabel || 'UNKNOWN'); + await this.setCapabilityValue('measure_polestarOtaState', stateText); + await this.setCapabilityValue('measure_polestarOtaVersion', ota.newVersion || ''); + } catch (err) { + if (err.message === 'Not logged in') { await this.attemptReLogin(); return; } + if (isUnimplementedError(err)) return; + this.homey.app.log('Failed to retrieve OTA state', this.name, 'DEBUG', err.message); + } + } + + async updateLocationState() { + if (this._destroyed) return; + if (!this.polestar || typeof this.polestar.getLocation !== 'function') return; + try { + const loc = await this.polestar.getLocation(); + if (!loc || !Number.isFinite(loc.latitude) || !Number.isFinite(loc.longitude)) return; + this._lastLocation = loc; + const str = `${loc.latitude.toFixed(5)}, ${loc.longitude.toFixed(5)}`; + await this.setCapabilityValue('measure_polestarLocation', str); + } catch (err) { + if (err.message === 'Not logged in') { await this.attemptReLogin(); return; } + this.homey.app.log('Failed to retrieve location', this.name, 'DEBUG', err.message); + } + } + + /** Called by the get_location flow action; returns lat/lng tokens. */ + async getLocationForFlow() { + await this.updateLocationState(); + const loc = this._lastLocation || {}; + return { + latitude: Number.isFinite(loc.latitude) ? loc.latitude : 0, + longitude: Number.isFinite(loc.longitude) ? loc.longitude : 0, + location: this.getCapabilityValue('measure_polestarLocation') || '', + }; + } + + async fixEnergy() + { + const currentEnergy = await this.getEnergy(); + //Check if this ev was created with the right energy object + if(!currentEnergy?.electricCar) + { + await this.setEnergy({ + "electricCar": true + }) + } } async fixCapabilities() { if (!this.hasCapability('measure_battery')) await this.addCapability('measure_battery'); - // if(!this.hasCapability('measure_current')) - // await this.addCapability('measure_current'); - // if(!this.hasCapability('measure_power')) - // await this.addCapability('measure_power'); + if (!this.hasCapability('ev_charging_state')) + await this.addCapability('ev_charging_state'); + if (!this.hasCapability('measure_polestarBattery')) + await this.addCapability('measure_polestarBattery'); + if(!this.hasCapability('measure_current')) + await this.addCapability('measure_current'); + if(!this.hasCapability('measure_power')) + await this.addCapability('measure_power'); + if(!this.hasCapability('meter_power')) + await this.addCapability('meter_power'); if (!this.hasCapability('measure_vehicleChargeTimeRemaining')) await this.addCapability('measure_vehicleChargeTimeRemaining'); if (!this.hasCapability('measure_vehicleOdometer')) @@ -57,46 +245,754 @@ class PolestarVehicle extends Device { await this.addCapability('measure_vehicleChargeState'); if (!this.hasCapability('measure_vehicleConnected')) await this.addCapability('measure_vehicleConnected'); + if (!this.hasCapability('alarm_generic')) + await this.addCapability('alarm_generic'); + if (!this.hasCapability('measure_vehicleDaysTillService')) + await this.addCapability('measure_vehicleDaysTillService'); + if (!this.hasCapability('measure_vehicleDistanceTillService')) + await this.addCapability('measure_vehicleDistanceTillService'); + if (!this.hasCapability('measure_voltage')) + await this.addCapability('measure_voltage'); + if (!this.hasCapability('measure_polestarChargingType')) + await this.addCapability('measure_polestarChargingType'); + if (!this.hasCapability('measure_polestarDrivingKwh')) + await this.addCapability('measure_polestarDrivingKwh'); + if (!this.hasCapability('measure_polestarSessionKwh')) + await this.addCapability('measure_polestarSessionKwh'); + if (!this.hasCapability('alarm_polestarTyrePressure')) + await this.addCapability('alarm_polestarTyrePressure'); + // Optional features — only add the slider if we haven't previously learned + // this vehicle doesn't support the underlying service (e.g. Polestar 4 AMP_LIMIT). + if (!this._isFeatureUnsupported('target_soc')) { + if (!this.hasCapability('target_polestarChargeLimit')) + await this.addCapability('target_polestarChargeLimit'); + } else if (this.hasCapability('target_polestarChargeLimit')) { + try { await this.removeCapability('target_polestarChargeLimit'); } catch (_) {} + } + if (!this._isFeatureUnsupported('amp_limit')) { + if (!this.hasCapability('target_polestarAmpLimit')) + await this.addCapability('target_polestarAmpLimit'); + } else if (this.hasCapability('target_polestarAmpLimit')) { + try { await this.removeCapability('target_polestarAmpLimit'); } catch (_) {} + } + if (!this.hasCapability('button.charge_start')) + await this.addCapability('button.charge_start'); + if (!this.hasCapability('button.charge_stop')) + await this.addCapability('button.charge_stop'); + if (!this.hasCapability('button.honk_flash')) + await this.addCapability('button.honk_flash'); + if (!this.hasCapability('button.unlock_trunk')) + await this.addCapability('button.unlock_trunk'); + // Windows are optional — skipped on vehicles that don't support remote + // window control (e.g. Polestar 4 responds UNIMPLEMENTED). Users can + // also toggle this off manually via the 'Windows remote control' device setting. + if (!this._isFeatureUnsupported('windows')) { + if (!this.hasCapability('button.windows_open')) await this.addCapability('button.windows_open'); + if (!this.hasCapability('button.windows_close')) await this.addCapability('button.windows_close'); + } else { + for (const c of ['button.windows_open', 'button.windows_close']) { + if (this.hasCapability(c)) { try { await this.removeCapability(c); } catch (_) {} } + } + } + + // Exterior + climate states (read-only now, future-setable via capabilitiesOptions). + for (const cap of [ + 'locked', + 'onoff.climate', + 'target_temperature', + 'measure_temperature', + 'measure_polestarClimateRemaining', + 'alarm_contact.door_front_left', + 'alarm_contact.door_front_right', + 'alarm_contact.door_rear_left', + 'alarm_contact.door_rear_right', + 'alarm_contact.window_any', + 'alarm_contact.tailgate', + 'alarm_contact.hood', + 'alarm_contact.sunroof', + 'alarm_contact.tank_lid', + 'measure_polestarLocation', + 'alarm_polestarOtaAvailable', + 'measure_polestarOtaState', + 'measure_polestarOtaVersion', + ]) { + if (!this.hasCapability(cap)) await this.addCapability(cap); + } + + for (const sub of ['front_left', 'front_right', 'rear_left', 'rear_right']) { + const id = `measure_pressure.${sub}`; + if (!this.hasCapability(id)) await this.addCapability(id); + } + } + + async updateHealthState(){ + if (this._destroyed) return; + this.homey.app.log('Retrieve vehicle health', 'PolestarVehicle', 'DEBUG'); + try { + var healthInfo = await this.polestar.getHealthData(); + this.homey.app.log('Health:', 'PolestarVehicle', 'DEBUG', healthInfo); + if(healthInfo!=null) + { + this.setCapabilityValue('alarm_generic', healthInfo.serviceWarning!='SERVICE_WARNING_NO_WARNING'); + this.setCapabilityValue('measure_vehicleDaysTillService', healthInfo.daysToService); + // Convert distance to service based on user preference + let distanceToService = healthInfo.distanceToServiceKm; + if (this.usesMiles() && distanceToService != null) { + distanceToService = Math.floor(distanceToService * KM_TO_MILES); + } else if (distanceToService != null) { + distanceToService = Math.floor(distanceToService); + } + this.setCapabilityValue('measure_vehicleDistanceTillService', distanceToService); + + // C3-only fields — legacy GraphQL does not supply these, so guard each one. + if (healthInfo.tyrePressures) { + const tp = healthInfo.tyrePressures; + if (Number.isFinite(tp.frontLeftKpa)) this.setCapabilityValue('measure_pressure.front_left', tp.frontLeftKpa); + if (Number.isFinite(tp.frontRightKpa)) this.setCapabilityValue('measure_pressure.front_right', tp.frontRightKpa); + if (Number.isFinite(tp.rearLeftKpa)) this.setCapabilityValue('measure_pressure.rear_left', tp.rearLeftKpa); + if (Number.isFinite(tp.rearRightKpa)) this.setCapabilityValue('measure_pressure.rear_right', tp.rearRightKpa); + } + if (typeof healthInfo.anyTyreWarning === 'boolean') { + this.setCapabilityValue('alarm_polestarTyrePressure', healthInfo.anyTyreWarning); + } + } else { + this.setCapabilityValue('alarm_generic', false); + } + } catch (err) { + if (err.message === 'Not logged in') { + this.homey.app.log('Session expired, attempting to re-login', 'PolestarVehicle', 'WARNING'); + await this.attemptReLogin(); + } else { + this.homey.app.log('Failed to retrieve health state', 'PolestarVehicle', 'ERROR', err); + } + } } async updateVehicleState() { + if (this._destroyed) return; this.homey.app.log('Retrieve device details', 'PolestarVehicle', 'DEBUG'); try { var odometer = await this.polestar.getOdometer(); + this.homey.app.log('Odometers:', 'PolestarVehicle', 'DEBUG', odometer); var odo = odometer.odometerMeters; try { odo = odo / 1000; //Convert to KM instead of M + if (this.usesMiles()) { + odo = Math.floor(odo * KM_TO_MILES); //Convert to miles + } else { + odo = Math.floor(odo); + } } catch { odo = null; } - this.homey.app.log('KM:' + odo, 'PolestarVehicle', 'DEBUG'); + this.homey.app.log((this.usesMiles() ? 'Miles:' : 'KM:') + odo, 'PolestarVehicle', 'DEBUG'); this.setCapabilityValue('measure_vehicleOdometer', odo); - } catch { - this.homey.app.log('Failed to retrieve odometer', 'PolestarVehicle', 'ERROR'); + } catch (err) { + if (err.message === 'Not logged in') { + this.homey.app.log('Session expired, attempting to re-login', 'PolestarVehicle', 'WARNING'); + await this.attemptReLogin(); + return; // Exit early, next interval will retry + } + this.homey.app.log('Failed to retrieve odometer', 'PolestarVehicle', 'ERROR', err); }; try { var batteryInfo = await this.polestar.getBattery(); this.homey.app.log('Battery:', 'PolestarVehicle', 'DEBUG', batteryInfo); - this.setCapabilityValue('measure_battery', batteryInfo.batteryChargeLevelPercentage); - // this.setCapabilityValue('measure_current', batteryInfo.chargingCurrentAmps); - // this.setCapabilityValue('measure_power', batteryInfo.chargingPowerWatts); + const batterySoc = Math.floor(batteryInfo.batteryChargeLevelPercentage); + this.setCapabilityValue('measure_polestarBattery', batterySoc); + this.setCapabilityValue('measure_battery', batterySoc); + // Restored charging metrics — C3 fills these reliably; GraphQL used to leave them null. + const amps = Number.isFinite(batteryInfo.chargingCurrentAmps) ? batteryInfo.chargingCurrentAmps : 0; + const watts = Number.isFinite(batteryInfo.chargingPowerWatts) ? batteryInfo.chargingPowerWatts : 0; + const volts = Number.isFinite(batteryInfo.chargingVoltageVolts) ? batteryInfo.chargingVoltageVolts : 0; + this.setCapabilityValue('measure_current', amps); + this.setCapabilityValue('measure_power', watts); + this.setCapabilityValue('measure_voltage', volts); + const isCharging = batteryInfo.chargingStatus === 'CHARGING_STATUS_CHARGING'; + const hoursPerPoll = measureInterval / (1000 * 60 * 60); + const deltaKwh = isCharging && watts > 0 ? (watts / 1000) * hoursPerPoll : 0; - this.setCapabilityValue('measure_vehicleRange', batteryInfo.estimatedDistanceToEmptyKm); - if (batteryInfo.chargingStatus == 'CHARGING_STATUS_CHARGING') { - this.setCapabilityValue('measure_vehicleChargeState', true); - this.setCapabilityValue('measure_vehicleChargeTimeRemaining', batteryInfo.estimatedChargingTimeToFullMinutes); + // meter_power — monotonic lifetime charging meter. + // Never reset, never overwritten by C3 driving-consumption (different semantics). + let meterPower = this.getCapabilityValue('meter_power'); + if (meterPower === null || meterPower === undefined) meterPower = 0; + if (deltaKwh > 0) meterPower += deltaKwh; + this.setCapabilityValue('meter_power', meterPower); + + // measure_polestarSessionKwh — resets on idle→charging transition, accumulates + // while charging, holds the last session total while idle. + const wasCharging = this._wasCharging === true; + let sessionKwh = this.getCapabilityValue('measure_polestarSessionKwh'); + if (sessionKwh === null || sessionKwh === undefined) sessionKwh = 0; + if (isCharging && !wasCharging) sessionKwh = 0; // new session starts + if (deltaKwh > 0) sessionKwh += deltaKwh; + this.setCapabilityValue('measure_polestarSessionKwh', sessionKwh); + this._wasCharging = isCharging; + + // measure_polestarDrivingKwh — monotonic lifetime driving consumption from C3. + // Guard against downward jumps (rare but cheap to ignore). + if (Number.isFinite(batteryInfo.totalEnergyConsumedKwh) && batteryInfo.totalEnergyConsumedKwh > 0) { + const prevDriving = this.getCapabilityValue('measure_polestarDrivingKwh') || 0; + if (batteryInfo.totalEnergyConsumedKwh >= prevDriving) { + this.setCapabilityValue('measure_polestarDrivingKwh', batteryInfo.totalEnergyConsumedKwh); + } + } + + if (batteryInfo.chargingTypeLabel) { + this.setCapabilityValue('measure_polestarChargingType', batteryInfo.chargingTypeLabel); + } + + //Set the estimated range for the vehicle based on user preference + let range; + if (this.usesMiles() && batteryInfo.estimatedDistanceToEmptyMiles != null) { + range = Math.floor(batteryInfo.estimatedDistanceToEmptyMiles); + } else if (this.usesMiles()) { + // Fallback: convert km to miles if miles not available from API + range = Math.floor(batteryInfo.estimatedDistanceToEmptyKm * KM_TO_MILES); } else { - this.setCapabilityValue('measure_vehicleChargeState', false); - this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + range = Math.floor(batteryInfo.estimatedDistanceToEmptyKm); } - if (batteryInfo.chargerConnectionStatus == 'CHARGER_CONNECTION_STATUS_CONNECTED') + this.setCapabilityValue('measure_vehicleRange', range); + + // C3 exposes charger_connection_status as a separate, authoritative field. + // Fall back to the chargingStatus heuristic only when that label is absent (legacy client). + const connectedByStatus = new Set([ + 'CHARGING_STATUS_CHARGING', + 'CHARGING_STATUS_DONE', + 'CHARGING_STATUS_SCHEDULED', + 'CHARGING_STATUS_SMART_CHARGING', + 'CHARGING_STATUS_SMART_CHARGING_PAUSED', + 'CHARGING_STATUS_ERROR', + 'CHARGING_STATUS_FAULT' + ]); + const isConnected = batteryInfo.chargerConnectionStatusLabel + ? batteryInfo.chargerConnectionStatusLabel === 'CONNECTED' + : connectedByStatus.has(batteryInfo.chargingStatus); + + if (isConnected) { this.setCapabilityValue('measure_vehicleConnected', true); - else + } else { this.setCapabilityValue('measure_vehicleConnected', false); - } catch { - this.homey.app.log('Failed to retrieve batterystate', 'PolestarVehicle', 'ERROR'); + this.setCapabilityValue('ev_charging_state', 'plugged_out'); + } + + switch (batteryInfo.chargingStatus) { + case 'CHARGING_STATUS_CHARGING': + this.setCapabilityValue('measure_vehicleChargeState', true); + this.setCapabilityValue('ev_charging_state', 'plugged_in_charging'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', batteryInfo.estimatedChargingTimeToFullMinutes); + break; + case 'CHARGING_STATUS_IDLE': + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', isConnected ? 'plugged_in' : 'plugged_out'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + break; + case 'CHARGING_STATUS_DONE': + // Target SoC reached — connector still in, no power flowing, no + // resume queued. Homey's "plugged_in_paused" implies a deliberate + // pause, which misrepresents a completed session. + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', 'plugged_in'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + break; + case 'CHARGING_STATUS_SCHEDULED': + case 'CHARGING_STATUS_SMART_CHARGING': + case 'CHARGING_STATUS_SMART_CHARGING_PAUSED': + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', 'plugged_in_paused'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + break; + case 'CHARGING_STATUS_DISCHARGING': + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', 'plugged_in'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + break; + + case 'CHARGING_STATUS_ERROR': + case 'CHARGING_STATUS_FAULT': + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', 'plugged_in'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + // TODO: Add capability to show charging error + break; + default: + this.setCapabilityValue('measure_vehicleChargeState', false); + this.setCapabilityValue('ev_charging_state', 'plugged_out'); + this.setCapabilityValue('measure_vehicleChargeTimeRemaining', null); + break; + } + + // if (batteryInfo.chargerConnectionStatus == 'CHARGER_CONNECTION_STATUS_CONNECTED') + // this.setCapabilityValue('measure_vehicleConnected', true); + // else + // this.setCapabilityValue('measure_vehicleConnected', false); + } catch (err) { + if (err.message === 'Not logged in') { + this.homey.app.log('Session expired, attempting to re-login', 'PolestarVehicle', 'WARNING'); + await this.attemptReLogin(); + return; // Exit early, next interval will retry + } + this.homey.app.log('Failed to retrieve batterystate', 'PolestarVehicle', 'ERROR', err); + } + + // Also refresh the user-setable charge limit every tick. The Get call is a single + // server-streaming frame (cheap) and catches changes the user makes in the Polestar + // app / in-car menu within the 60 s window instead of the 15-min slow cycle. + await this.refreshChargeLimit(); + await this.updateExteriorState(); + await this.updateClimateState(); + + this.homey.api.realtime('updatevehicle'); + } + + async updateExteriorState() { + if (this._destroyed) return; + if (!this.polestar || typeof this.polestar.getExterior !== 'function') return; + try { + const ext = await this.polestar.getExterior(); + if (!ext) return; + this.homey.app.log('Exterior:', this.name, 'DEBUG', ext); + + if (typeof ext.isLocked === 'boolean') { + await this.setCapabilityValue('locked', ext.isLocked); + } + await this.setCapabilityValue('alarm_contact.door_front_left', !!ext.doors.frontLeftOpen); + await this.setCapabilityValue('alarm_contact.door_front_right', !!ext.doors.frontRightOpen); + await this.setCapabilityValue('alarm_contact.door_rear_left', !!ext.doors.rearLeftOpen); + await this.setCapabilityValue('alarm_contact.door_rear_right', !!ext.doors.rearRightOpen); + await this.setCapabilityValue('alarm_contact.window_any', !!ext.windows.anyOpen); + await this.setCapabilityValue('alarm_contact.tailgate', !!ext.tailgateOpen); + await this.setCapabilityValue('alarm_contact.hood', !!ext.hoodOpen); + await this.setCapabilityValue('alarm_contact.sunroof', !!ext.sunroofOpen); + await this.setCapabilityValue('alarm_contact.tank_lid', !!ext.tankLidOpen); + } catch (err) { + if (err.message === 'Not logged in') { + await this.attemptReLogin(); + return; + } + this.homey.app.log('Failed to retrieve exterior state', this.name, 'ERROR', err); + } + } + + async updateClimateState() { + if (this._destroyed) return; + if (!this.polestar || typeof this.polestar.getClimate !== 'function') return; + try { + const cl = await this.polestar.getClimate(); + if (!cl) return; + this.homey.app.log('Climate:', this.name, 'DEBUG', cl); + + await this.setCapabilityValue('onoff.climate', !!cl.isActive); + // Polestar often encodes temps in tenths of °C. Heuristic: if the raw number + // is clearly out of human range (<45 assumed to be whole °C, otherwise /10). + const normalizeTemp = (raw) => { + if (!Number.isFinite(raw) || raw <= 0) return null; + return raw <= 45 ? raw : raw / 10; + }; + const target = normalizeTemp(cl.requestedTempRaw); + const current = normalizeTemp(cl.currentTempRaw); + + // Only the ACTIVE response includes requested_temp. When idle, the tile would + // otherwise stay empty forever on a fresh device. Fall back to the configured + // default so users always see their preferred target. + if (target !== null) { + await this.setCapabilityValue('target_temperature', target); + } else if (this.getCapabilityValue('target_temperature') == null) { + const defaultTemp = Number(this.getSetting('climate_default_temp')); + if (Number.isFinite(defaultTemp) && defaultTemp >= 16 && defaultTemp <= 30) { + await this.setCapabilityValue('target_temperature', defaultTemp); + } + } + if (current !== null) await this.setCapabilityValue('measure_temperature', current); + + // Climate remaining minutes: only meaningful while ACTIVE; show null otherwise so + // the tile reads "—" rather than claiming 30 min forever when idle. + if (cl.isActive && Number.isFinite(cl.timeRemainingMinutes) && cl.timeRemainingMinutes > 0) { + await this.setCapabilityValue('measure_polestarClimateRemaining', cl.timeRemainingMinutes); + } else { + await this.setCapabilityValue('measure_polestarClimateRemaining', null); + } + } catch (err) { + if (err.message === 'Not logged in') { + await this.attemptReLogin(); + return; + } + if (isUnimplementedError(err)) { + // Some models might not support parking climatization — silent handle. + return; + } + this.homey.app.log('Failed to retrieve climate state', this.name, 'ERROR', err); + } + } + + /** + * Master-switch check for write commands. Defaults to allowed; users can + * flip the device setting off to kill-switch all write flows instantly. + */ + _requireWritesEnabled() { + const enabled = this.getSetting('writes_enabled'); + // Treat undefined (never set) as true to match default value in compose. + if (enabled === false) { + this.homey.app.log('Remote command blocked: writes_enabled is off', this.name, 'WARNING'); + throw new Error('Remote commands are disabled for this vehicle (see device settings)'); + } + } + + /** Guard a write command: master-switch check + client check + error logging. */ + async _invokeWrite(label, fn) { + this._requireWritesEnabled(); + if (!this.polestar) throw new Error('Polestar client not ready'); + if (this.homey.settings.get('c3_backend_disabled') === true) { + throw new Error('C3 backend is disabled on this device — write commands unavailable'); + } + try { + const result = await fn(); + this.homey.app.log(`${label} OK`, this.name, 'DEBUG', result); + return result; + } catch (err) { + if (err.message === 'Not logged in') { + this.homey.app.log(`${label}: session expired, re-logging in`, this.name, 'WARNING'); + await this.attemptReLogin(); + const result = await fn(); + this.homey.app.log(`${label} OK (after re-login)`, this.name, 'DEBUG', result); + return result; + } + this.homey.app.log(`${label} FAILED`, this.name, 'ERROR', err); + // If the write revealed a feature isn't supported, clean up right away + // so the user won't see a sad slider next run. + if (isUnimplementedError(err)) { + const l = label.toLowerCase(); + const featureKey = + /amplimit|amp_limit/.test(l) ? 'amp_limit' : + /targetsoc|chargelimit/.test(l) ? 'target_soc' : + /windows/.test(l) ? 'windows' : + null; + if (featureKey) await this._markFeatureUnsupported(featureKey, err.message); + } + throw new Error(friendlyGrpcError(err.message, label)); + } + } + + async chargeStart() { + const r = await this._invokeWrite('chargeStart', () => this.polestar.chargeStart()); + this._scheduleStateRefresh(['vehicle']); + return r; + } + async chargeStop() { + const r = await this._invokeWrite('chargeStop', () => this.polestar.chargeStop()); + this._scheduleStateRefresh(['vehicle']); + return r; + } + async lockCar() { + const r = await this._invokeWrite('lock', () => this.polestar.lock()); + this._scheduleStateRefresh(['exterior']); + return r; + } + async unlockCar() { + const r = await this._invokeWrite('unlock', () => this.polestar.unlock()); + this._scheduleStateRefresh(['exterior']); + return r; + } + async unlockTrunkAction() { + const r = await this._invokeWrite('unlockTrunk', () => this.polestar.unlockTrunk()); + this._scheduleStateRefresh(['exterior']); + return r; + } + honkFlashAction(args) { + const actionMap = { flash: 2, honk: 1, both: 0 }; + const code = actionMap[args && args.action] !== undefined ? actionMap[args.action] : 2; + return this._invokeWrite('honkFlash', () => this.polestar.honkFlash({ action: code })); + } + async climateStartAction(args) { + const parse = (v) => { + const n = Number(v); + return Number.isInteger(n) && n >= 1 && n <= 4 ? n : 1; + }; + const opts = { + temperature: Number(args.temp), + frontLeftSeat: parse(args.seat_fl), + frontRightSeat: parse(args.seat_fr), + rearLeftSeat: parse(args.seat_rl), + rearRightSeat: parse(args.seat_rr), + steeringWheel: parse(args.wheel), + }; + const r = await this._invokeWrite('climateStart', () => this.polestar.climateStart(opts)); + this._scheduleStateRefresh(['climate']); + return r; + } + /** Start climate using the defaults from device settings — mirrors the tile toggle. */ + async climateStartSimpleAction() { + const opts = this._getClimateStartOptions(); + const r = await this._invokeWrite('climateStart', () => this.polestar.climateStart(opts)); + this._scheduleStateRefresh(['climate']); + return r; + } + async climateStopAction() { + const r = await this._invokeWrite('climateStop', () => this.polestar.climateStop()); + this._scheduleStateRefresh(['climate']); + return r; + } + async windowsOpenAction() { + const r = await this._invokeWrite('windowsOpen', () => this.polestar.windowsOpen()); + this._scheduleStateRefresh(['exterior'], { delays: [3000, 15000] }); + return r; + } + async windowsCloseAction() { + const r = await this._invokeWrite('windowsClose', () => this.polestar.windowsClose()); + this._scheduleStateRefresh(['exterior'], { delays: [3000, 15000] }); + return r; + } + + /** Called by the is_locked condition flow card. */ + isLocked() { + return this.getCapabilityValue('locked') === true; + } + + /** + * Schedule one or more delayed refreshes so the UI catches up with a write + * faster than the 60 s polling cycle. The first (~3 s) typically catches + * immediate state sync; the second (~10 s) catches cases where the car + * takes longer to propagate the new state back to C3. + */ + _scheduleStateRefresh(kinds, { delays = [3000, 10000] } = {}) { + for (const delay of delays) { + this.homey.setTimeout(async () => { + if (this._destroyed) return; + try { + if (kinds.includes('climate')) await this.updateClimateState(); + if (kinds.includes('exterior')) await this.updateExteriorState(); + if (kinds.includes('vehicle')) await this.updateVehicleState(); + } catch (_) { /* already logged inside each update method */ } + }, delay); + } + } + _getTargetSocSettingType() { + const raw = this.getSetting('target_soc_setting_type'); + const n = raw === undefined || raw === null ? 1 : Number(raw); + return Number.isInteger(n) && n >= 0 && n <= 3 ? n : 1; + } + + async setTargetSoc(args) { + const level = Math.round(args.level); + const slot = this._getTargetSocSettingType(); + const returned = await this._invokeWrite('setTargetSoc', + () => this.polestar.setTargetSoc(level, slot)); + await this._applyTargetSocResult(level, returned); + } + async setAmpLimit(args) { + const amps = Math.round(args.amperage); + const returned = await this._invokeWrite('setAmpLimit', () => this.polestar.setAmpLimit(amps)); + await this._applyAmpLimitResult(amps, returned); + } + + /** + * Intentionally DON'T trust the Set response — on Polestar 4 the server + * returns an echo of setting_type plus a stale copy of the previous level + * (field 1 of the inner payload), not the newly committed level. Leave the + * slider on what the user moved to and correct it 3 s later via a Get. + * If the Get still disagrees, warn the user once to check their slot setting. + */ + async _applyTargetSocResult(requested, _returnedIgnored) { + this.homey.setTimeout(async () => { + try { + const actual = await this.polestar.getTargetSoc(); + if (!Number.isFinite(actual)) return; + if (actual !== requested) { + await this.setCapabilityValue('target_polestarChargeLimit', actual); + this.homey.app.log( + `Charge limit did not change to ${requested}% (server reports ${actual}%). ` + + `Try switching 'Charge limit slot' in device settings.`, + this.name, 'WARNING'); + } + } catch (err) { this.homey.app.log('post-write SoC re-read failed', this.name, 'DEBUG', err.message); } + }, 3000); + } + + async _applyAmpLimitResult(requested, _returnedIgnored) { + this.homey.setTimeout(async () => { + try { + const actual = await this.polestar.getAmpLimit(); + if (!Number.isFinite(actual)) return; + if (actual !== requested) { + await this.setCapabilityValue('target_polestarAmpLimit', actual); + this.homey.app.log(`Amp limit differs after write: requested ${requested}A, server reports ${actual}A`, + this.name, 'WARNING'); + } + } catch (err) { this.homey.app.log('post-write amp limit re-read failed', this.name, 'DEBUG', err.message); } + }, 3000); + } + + /** Check if the backend has previously told us this feature isn't supported on this vehicle. */ + _isFeatureUnsupported(key) { + const unsupported = this.getStoreValue('unsupportedFeatures') || {}; + return unsupported[key] === true; + } + + /** Mark a feature as unsupported based on a gRPC UNIMPLEMENTED response, + * remove its capabilities, and log once. */ + async _markFeatureUnsupported(key, reason = '') { + if (this._isFeatureUnsupported(key)) return; // already marked + const spec = OPTIONAL_FEATURES[key]; + if (!spec) return; + const unsupported = { ...(this.getStoreValue('unsupportedFeatures') || {}), [key]: true }; + await this.setStoreValue('unsupportedFeatures', unsupported); + this.homey.app.log(`Feature '${key}' not supported on this vehicle — removing related capabilities. ${reason}`, + this.name, 'WARNING'); + for (const cap of spec.capabilities) { + if (this.hasCapability(cap)) { + try { await this.removeCapability(cap); } + catch (err) { this.homey.app.log(`Failed to remove ${cap}`, this.name, 'WARNING', err); } + } + } + } + + /** Register tile/slider handlers for the setable write capabilities. */ + _registerWriteCapabilityListeners() { + this.registerCapabilityListener('target_polestarChargeLimit', async (value) => { + const level = Math.round(value); + const slot = this._getTargetSocSettingType(); + const returned = await this._invokeWrite('target_polestarChargeLimit', + () => this.polestar.setTargetSoc(level, slot)); + await this._applyTargetSocResult(level, returned); + }); + this.registerCapabilityListener('target_polestarAmpLimit', async (value) => { + const amps = Math.round(value); + const returned = await this._invokeWrite('target_polestarAmpLimit', + () => this.polestar.setAmpLimit(amps)); + await this._applyAmpLimitResult(amps, returned); + }); + this.registerCapabilityListener('button.charge_start', async () => { + await this._invokeWrite('button.charge_start', () => this.polestar.chargeStart()); + }); + this.registerCapabilityListener('button.charge_stop', async () => { + await this._invokeWrite('button.charge_stop', () => this.polestar.chargeStop()); + }); + + this.registerCapabilityListener('locked', async (value) => { + const label = value ? 'lock' : 'unlock'; + await this._invokeWrite(label, () => value ? this.polestar.lock() : this.polestar.unlock()); + this._scheduleStateRefresh(['exterior']); + }); + + this.registerCapabilityListener('button.honk_flash', async () => { + await this._invokeWrite('button.honk_flash', () => this.polestar.honkFlash()); + // No state change — honk/flash is fire-and-forget. + }); + + this.registerCapabilityListener('button.unlock_trunk', async () => { + await this._invokeWrite('button.unlock_trunk', () => this.polestar.unlockTrunk()); + this._scheduleStateRefresh(['exterior']); + }); + this.registerCapabilityListener('button.windows_open', async () => { + await this._invokeWrite('button.windows_open', () => this.polestar.windowsOpen()); + this._scheduleStateRefresh(['exterior'], { delays: [3000, 15000] }); + }); + this.registerCapabilityListener('button.windows_close', async () => { + await this._invokeWrite('button.windows_close', () => this.polestar.windowsClose()); + this._scheduleStateRefresh(['exterior'], { delays: [3000, 15000] }); + }); + + this.registerCapabilityListener('onoff.climate', async (value) => { + if (value) { + const opts = this._getClimateStartOptions(); + await this._invokeWrite('climateStart', () => this.polestar.climateStart(opts)); + } else { + await this._invokeWrite('climateStop', () => this.polestar.climateStop()); + } + // Climate status (including time_remaining) updates slower than the write ack; + // refresh at 3 s for first sync and again at 10 s to catch the server settling. + this._scheduleStateRefresh(['climate']); + }); + + this.registerCapabilityListener('target_temperature', async (temperature) => { + // Target temperature alone doesn't start climate — it just updates the stored + // default so the next climateStart uses the new value. If climate is already + // ACTIVE, the user can toggle onoff.climate to restart with the new target. + await this.setSettings({ climate_default_temp: Number(temperature) }); + }); + } + + /** Read climate defaults from device settings and build the climateStart args. */ + _getClimateStartOptions() { + const parseLevel = (raw) => { + const n = Number(raw); + return Number.isInteger(n) && n >= 1 && n <= 4 ? n : 1; // default OFF + }; + const tileTarget = this.getCapabilityValue('target_temperature'); + const settingTemp = this.getSetting('climate_default_temp'); + const temperature = Number.isFinite(tileTarget) && tileTarget >= 16 && tileTarget <= 30 + ? tileTarget + : (Number.isFinite(Number(settingTemp)) ? Number(settingTemp) : 21); + return { + temperature, + frontLeftSeat: parseLevel(this.getSetting('climate_seat_front_left')), + frontRightSeat: parseLevel(this.getSetting('climate_seat_front_right')), + rearLeftSeat: parseLevel(this.getSetting('climate_seat_rear_left')), + rearRightSeat: parseLevel(this.getSetting('climate_seat_rear_right')), + steeringWheel: parseLevel(this.getSetting('climate_steering_wheel')), + }; + } + + /** + * Populate the read-side of the target_* capabilities. Called at init and + * after any flow-card or tile change so the slider reflects reality. + * Silently tolerates UNIMPLEMENTED (Polestar 4 amp limit case). + */ + /** Fast-cycle refresh: charge limit only. User can change it several times a day + * (via Polestar app, in-car menu), so keep up with the 60 s cycle. */ + async refreshChargeLimit() { + if (!this.polestar || typeof this.polestar.getTargetSoc !== 'function') return; + if (this._isFeatureUnsupported('target_soc')) return; + try { + const soc = await this.polestar.getTargetSoc(); + if (Number.isFinite(soc) && soc >= 50 && soc <= 100) { + await this.setCapabilityValue('target_polestarChargeLimit', soc); + } + } catch (err) { + if (isUnimplementedError(err)) await this._markFeatureUnsupported('target_soc', err.message); + else this.homey.app.log('refresh target SoC failed', this.name, 'DEBUG', err.message); + } + } + + /** Slow-cycle refresh: amp limit. Changes rarely (per charging location), so 15 min + * is plenty and saves a gRPC round-trip every minute. Skipped entirely on Polestar 4. */ + async refreshAmpLimit() { + if (!this.polestar || typeof this.polestar.getAmpLimit !== 'function') return; + if (this._isFeatureUnsupported('amp_limit')) return; + try { + const amps = await this.polestar.getAmpLimit(); + if (Number.isFinite(amps) && amps >= 6 && amps <= 32) { + await this.setCapabilityValue('target_polestarAmpLimit', amps); + } + } catch (err) { + if (isUnimplementedError(err)) await this._markFeatureUnsupported('amp_limit', err.message); + else this.homey.app.log('refresh amp limit failed', this.name, 'DEBUG', err.message); + } + } + + /** Back-compat shim for callers that still invoke the old combined method. */ + async refreshChargingTargets() { + await this.refreshChargeLimit(); + await this.refreshAmpLimit(); + } + + async getCurrentTargetSoc() { + if (!this.polestar) return null; + try { return await this.polestar.getTargetSoc(); } + catch (err) { + this.homey.app.log('getTargetSoc failed', this.name, 'ERROR', err); + return null; + } + } + + async getCurrentAmpLimit() { + if (!this.polestar) return null; + try { return await this.polestar.getAmpLimit(); } + catch (err) { + this.homey.app.log('getAmpLimit failed', this.name, 'ERROR', err); + return null; } } @@ -105,7 +1001,20 @@ class PolestarVehicle extends Device { } async onSettings({ oldSettings, newSettings, changedKeys }) { - this.homey.app.log('PolestarVehicle settings where changed', 'PolestarVehicle'); + this.homey.app.log('PolestarVehicle settings changed', 'PolestarVehicle', 'DEBUG', changedKeys); + + if (changedKeys.includes('windows_supported')) { + if (newSettings.windows_supported === false) { + await this._markFeatureUnsupported('windows', 'disabled via device setting'); + } else { + // Manually re-enable: clear store flag then re-add capabilities. + const store = { ...(this.getStoreValue('unsupportedFeatures') || {}) }; + delete store.windows; + await this.setStoreValue('unsupportedFeatures', store); + await this.fixCapabilities(); + this.homey.app.log('Windows remote control re-enabled — caps restored', this.name, 'DEBUG'); + } + } } async onRenamed(name) { @@ -114,6 +1023,22 @@ class PolestarVehicle extends Device { async onDeleted() { this.homey.app.log('PolestarVehicle has been deleted', 'PolestarVehicle'); + this._cleanup(); + } + + async onUninit() { + this._cleanup(); + } + + /** Stop all timers and close the gRPC session so stale polls can't hit a + * device that's already been removed or is re-initialising. */ + _cleanup() { + this._destroyed = true; + if (this._timerTimers) { try { this.homey.clearInterval(this._timerTimers); } catch (_) {} this._timerTimers = null; } + if (this._timerHealth) { try { this.homey.clearInterval(this._timerHealth); } catch (_) {} this._timerHealth = null; } + if (this.polestar && typeof this.polestar.close === 'function') { + try { this.polestar.close(); } catch (_) {} + } } } diff --git a/drivers/vehicle/driver.compose.json b/drivers/vehicle/driver.compose.json index 75c2342..5909517 100644 --- a/drivers/vehicle/driver.compose.json +++ b/drivers/vehicle/driver.compose.json @@ -4,12 +4,135 @@ "no": "Min Polestar", "nl": "Mijn Polestar" }, - "class": "other", + "class": "car", "capabilities": [ - "measure_battery" + "measure_battery", + "ev_charging_state", + "measure_power", + "measure_current", + "measure_voltage", + "meter_power", + "measure_polestarDrivingKwh", + "measure_polestarSessionKwh", + "measure_polestarChargingType", + "target_polestarChargeLimit", + "target_polestarAmpLimit", + "button.charge_start", + "button.charge_stop", + "button.honk_flash", + "button.unlock_trunk", + "button.windows_open", + "button.windows_close", + "locked", + "onoff.climate", + "target_temperature", + "measure_temperature", + "measure_polestarClimateRemaining", + "measure_polestarLocation", + "alarm_polestarOtaAvailable", + "measure_polestarOtaState", + "measure_polestarOtaVersion", + "alarm_contact.door_front_left", + "alarm_contact.door_front_right", + "alarm_contact.door_rear_left", + "alarm_contact.door_rear_right", + "alarm_contact.window_any", + "alarm_contact.tailgate", + "alarm_contact.hood", + "alarm_contact.sunroof", + "alarm_contact.tank_lid", + "alarm_polestarTyrePressure", + "measure_pressure.front_left", + "measure_pressure.front_right", + "measure_pressure.rear_left", + "measure_pressure.rear_right" ], "energy": { - "batteries": ["INTERNAL"] + "electricCar": true + }, + "capabilitiesOptions": { + "button.charge_start": { + "title": { "en": "Start charging", "no": "Start lading", "nl": "Laden starten" }, + "desc": { "en": "Override the charge timer and start charging now.", "no": "Overstyr ladetidtakeren og start lading nå.", "nl": "Negeer de laadtimer en begin direct met laden." } + }, + "button.charge_stop": { + "title": { "en": "Stop charging", "no": "Stopp lading", "nl": "Laden stoppen" }, + "desc": { "en": "Cancel the charge-now override.", "no": "Avbryt lad-nå-overstyringen.", "nl": "Heft de lad-nu-override op." } + }, + "button.honk_flash": { + "title": { "en": "Honk and flash", "no": "Tut og blink", "nl": "Toeter en knipper" }, + "desc": { "en": "Find the car — honk horn and flash lights.", "no": "Finn bilen — tut og blink med lysene.", "nl": "Vind de auto — toeteren en lichten knipperen." } + }, + "button.unlock_trunk": { + "title": { "en": "Unlock trunk", "no": "Lås opp bagasjerom", "nl": "Kofferbak ontgrendelen" }, + "desc": { "en": "Unlock only the trunk, leaving the cabin locked.", "no": "Lås opp kun bagasjerommet.", "nl": "Ontgrendel alleen de kofferbak; het interieur blijft vergrendeld." } + }, + "button.windows_open": { + "title": { "en": "Open windows", "no": "Åpne vinduer", "nl": "Ramen openen" }, + "desc": { "en": "Open all four side windows.", "no": "Åpne alle fire sidevinduer.", "nl": "Open alle vier zijramen." } + }, + "button.windows_close": { + "title": { "en": "Close windows", "no": "Lukk vinduer", "nl": "Ramen sluiten" }, + "desc": { "en": "Close all side windows.", "no": "Lukk alle sidevinduer.", "nl": "Sluit alle zijramen." } + }, + "measure_pressure.front_left": { + "title": { "en": "Tyre pressure FL", "no": "Dekktrykk FV", "nl": "Bandenspanning LV" }, + "units": { "en": "kPa" }, + "decimals": 0, + "insights": true + }, + "measure_pressure.front_right": { + "title": { "en": "Tyre pressure FR", "no": "Dekktrykk FH", "nl": "Bandenspanning RV" }, + "units": { "en": "kPa" }, + "decimals": 0, + "insights": true + }, + "measure_pressure.rear_left": { + "title": { "en": "Tyre pressure RL", "no": "Dekktrykk BV", "nl": "Bandenspanning LA" }, + "units": { "en": "kPa" }, + "decimals": 0, + "insights": true + }, + "measure_pressure.rear_right": { + "title": { "en": "Tyre pressure RR", "no": "Dekktrykk BH", "nl": "Bandenspanning RA" }, + "units": { "en": "kPa" }, + "decimals": 0, + "insights": true + }, + "locked": { + "title": { "en": "Locked", "no": "Låst", "nl": "Vergrendeld" }, + "desc": { "en": "Central-lock state. Toggle to lock/unlock the car.", + "no": "Status for sentrallås. Trykk for å låse / låse opp bilen.", + "nl": "Centrale-vergrendelingsstatus. Schakel om de auto te vergrendelen of ontgrendelen." } + }, + "onoff.climate": { + "title": { "en": "Climate", "no": "Klima", "nl": "Klimaat" }, + "desc": { "en": "Start or stop parking climatization. Uses the heating settings from device settings.", + "no": "Start eller stopp klimaanlegg. Bruker oppvarmingsinnstillinger fra enhetsinnstillinger.", + "nl": "Start of stop de parkeer-klimaatregeling. Gebruikt de verwarmingsinstellingen uit de device-settings." } + }, + "target_temperature": { + "min": 16, + "max": 30, + "step": 0.5, + "decimals": 1, + "title": { "en": "Climate target", "no": "Klima-mål", "nl": "Klimaatdoel" }, + "desc": { "en": "Desired climatization temperature. Used as default when starting climate from the toggle.", + "no": "Ønsket klimatemperatur. Brukes som standard ved start via klima-bryteren.", + "nl": "Gewenste klimaattemperatuur. Wordt als default gebruikt bij het aanzetten via de climate-toggle." } + }, + "measure_temperature": { + "title": { "en": "Interior temperature", "no": "Innetemperatur", "nl": "Binnentemperatuur" } + }, + "alarm_contact.door_front_left": { "title": { "en": "Door FL open", "no": "Dør FV åpen", "nl": "Deur LV open" } }, + "alarm_contact.door_front_right": { "title": { "en": "Door FR open", "no": "Dør FH åpen", "nl": "Deur RV open" } }, + "alarm_contact.door_rear_left": { "title": { "en": "Door RL open", "no": "Dør BV åpen", "nl": "Deur LA open" } }, + "alarm_contact.door_rear_right": { "title": { "en": "Door RR open", "no": "Dør BH åpen", "nl": "Deur RA open" } }, + "alarm_contact.window_any": { "title": { "en": "Window open", "no": "Vindu åpent", "nl": "Raam open" } }, + "alarm_contact.tailgate": { "title": { "en": "Tailgate open", "no": "Bakluke åpen", "nl": "Kofferbak open" } }, + "alarm_contact.hood": { "title": { "en": "Hood open", "no": "Panser åpent", "nl": "Motorkap open" } }, + "alarm_contact.sunroof": { "title": { "en": "Sunroof open", "no": "Takluke åpen", "nl": "Schuifdak open" } }, + "alarm_contact.tank_lid": { "title": { "en": "Charge port open", "no": "Ladeluke åpen", "nl": "Laadklep open" } } }, "platforms": [ "local" diff --git a/drivers/vehicle/driver.flow.compose.json b/drivers/vehicle/driver.flow.compose.json new file mode 100644 index 0000000..6a96ba4 --- /dev/null +++ b/drivers/vehicle/driver.flow.compose.json @@ -0,0 +1,316 @@ +{ + "triggers": [ + { + "id": "measure_vehicleChargeState_false", + "title": { + "en": "Car stopped charging", + "no": "Lading stoppet", + "nl": "Auto is gestopt met laden" + }, + "hint": { + "en": "When the car stopped drawing power from the socket", + "no": "Når bilen sluttet å trekke strøm fra laderen", + "nl": "Als de auto zelf gestopt is met het opnemen van stroom uit via laadpoort" + }, + "$filter": "capabilities=measure_vehicleChargeState" + }, + { + "id": "measure_vehicleChargeState_true", + "title": { + "en": "Car started charging", + "no": "Lading startet", + "nl": "Auto is begonnen met laden" + }, + "hint": { + "en": "When the car started to draw power from the socket", + "no": "Når bilen startet å trekke strøm fra laderen", + "nl": "Als de auto is begonnen met het opnemen van stroom via de laadpoort" + }, + "$filter": "capabilities=measure_vehicleChargeState" + }, + { + "id": "measure_vehicleConnected_false", + "title": { + "en": "Car disconnected from a charger", + "no": "Bil frakoblet lader", + "nl": "Auto is niet meer verbonden met de lader" + }, + "hint": { + "en": "When the car detects it is no longer connected to a charge port", + "no": "Når bilen oppdager at ladepunktet er frakoblet", + "nl": "Als de auto detecteerd dat de laadpoort niet meer verbonden is met een lader" + }, + "$filter": "capabilities=measure_vehicleConnected" + }, + { + "id": "measure_vehicleConnected_true", + "title": { + "en": "Car connected to a charger", + "no": "Bil tilkoblet lader", + "nl": "Auto is verbonden met een lader" + }, + "hint": { + "en": "When the car detects a charger connected to a charge port", + "no": "Når bilen oppdager at en lader er tilkoblet ladepunktet", + "nl": "Wanneer de auto detecteerd dat de laadpoort verbonden is met een lader" + }, + "$filter": "capabilities=measure_vehicleConnected" + } + ], + "conditions": [ + { + "id": "is_locked", + "title": { + "en": "Car !{{is|is not}} locked", + "no": "Bil !{{er|er ikke}} låst", + "nl": "Auto !{{is|is niet}} vergrendeld" + } + }, + { + "id": "target_soc_is", + "title": { + "en": "Charge limit !{{is|is not}} at least", + "no": "Ladegrense !{{er|er ikke}} minst", + "nl": "Laadlimiet !{{is|is niet}} minimaal" + }, + "titleFormatted": { + "en": "Charge limit !{{is|is not}} at least [[level]]%", + "no": "Ladegrense !{{er|er ikke}} minst [[level]]%", + "nl": "Laadlimiet !{{is|is niet}} minimaal [[level]]%" + }, + "args": [ + { "type": "number", "name": "level", "min": 50, "max": 100, "step": 1, "placeholder": { "en": "%", "no": "%", "nl": "%" } } + ] + }, + { + "id": "amp_limit_is", + "title": { + "en": "Amp limit !{{is|is not}} at least", + "no": "Amperebegrensning !{{er|er ikke}} minst", + "nl": "Amperebegrenzing !{{is|is niet}} minimaal" + }, + "titleFormatted": { + "en": "Amp limit !{{is|is not}} at least [[amperage]] A", + "no": "Amperebegrensning !{{er|er ikke}} minst [[amperage]] A", + "nl": "Amperebegrenzing !{{is|is niet}} minimaal [[amperage]] A" + }, + "args": [ + { "type": "number", "name": "amperage", "min": 6, "max": 32, "step": 1, "placeholder": { "en": "A", "no": "A", "nl": "A" } } + ] + } + ], + "actions": [ + { + "id": "charge_start", + "title": { + "en": "Start charging (override timer)", + "no": "Start lading (overstyr tidtaker)", + "nl": "Laden starten (negeer timer)" + }, + "titleFormatted": { + "en": "Start charging (override timer)", + "no": "Start lading (overstyr tidtaker)", + "nl": "Laden starten (negeer timer)" + }, + "hint": { + "en": "Overrides any scheduled charging timer and starts drawing power now. Requires the charger to be connected and 'Allow remote commands' to be enabled in device settings.", + "no": "Overstyr planlagt lading og start nå. Laderen må være tilkoblet og 'Tillat fjernkommandoer' må være på i enhetsinnstillingene.", + "nl": "Overschrijft een geplande laadtimer en begint direct met laden. Lader moet verbonden zijn en 'Afstandscommando's toestaan' moet aan staan in de device-instellingen." + } + }, + { + "id": "charge_stop", + "title": { + "en": "Stop charging (override timer)", + "no": "Stopp lading (overstyr tidtaker)", + "nl": "Laden stoppen (negeer timer)" + }, + "titleFormatted": { + "en": "Stop charging (override timer)", + "no": "Stopp lading (overstyr tidtaker)", + "nl": "Laden stoppen (negeer timer)" + }, + "hint": { + "en": "Cancels the charge-now override and lets the scheduled timer take over again.", + "no": "Avbryt lad-nå-overstyringen og overlat kontrollen til planlagt tidtaker.", + "nl": "Heft de lad-nu-override op; de geplande timer neemt het weer over." + } + }, + { + "id": "set_target_soc", + "title": { + "en": "Set charge limit", + "no": "Sett ladegrense", + "nl": "Laadlimiet instellen" + }, + "titleFormatted": { + "en": "Set charge limit to [[level]]%", + "no": "Sett ladegrense til [[level]]%", + "nl": "Laadlimiet instellen op [[level]]%" + }, + "hint": { + "en": "Target state-of-charge where the car stops charging (50–100%).", + "no": "Ladegrense der bilen stopper ladingen (50–100%).", + "nl": "SoC-doel waarop de auto stopt met laden (50–100%)." + }, + "args": [ + { "type": "number", "name": "level", "min": 50, "max": 100, "step": 1, "placeholder": { "en": "%", "no": "%", "nl": "%" } } + ] + }, + { + "id": "lock_car", + "title": { "en": "Lock car", "no": "Lås bil", "nl": "Auto vergrendelen" }, + "titleFormatted": { "en": "Lock car", "no": "Lås bil", "nl": "Auto vergrendelen" }, + "hint": { "en": "Activate the central lock.", + "no": "Aktiver sentrallås.", + "nl": "Activeert de centrale vergrendeling." } + }, + { + "id": "unlock_car", + "title": { "en": "Unlock car", "no": "Lås opp bil", "nl": "Auto ontgrendelen" }, + "titleFormatted": { "en": "Unlock car", "no": "Lås opp bil", "nl": "Auto ontgrendelen" }, + "hint": { "en": "Unlock all doors.", + "no": "Lås opp alle dører.", + "nl": "Ontgrendelt alle deuren." } + }, + { + "id": "unlock_trunk_action", + "title": { "en": "Unlock trunk", "no": "Lås opp bagasjerom", "nl": "Kofferbak ontgrendelen" }, + "titleFormatted": { "en": "Unlock trunk", "no": "Lås opp bagasjerom", "nl": "Kofferbak ontgrendelen" }, + "hint": { "en": "Unlock only the trunk; the cabin stays locked.", + "no": "Lås opp kun bagasjerommet; kupeen forblir låst.", + "nl": "Alleen de kofferbak ontgrendelen, interieur blijft vergrendeld." } + }, + { + "id": "honk_flash", + "title": { "en": "Find my car", "no": "Finn bilen", "nl": "Vind mijn auto" }, + "titleFormatted": { + "en": "Find my car — [[action]]", + "no": "Finn bilen — [[action]]", + "nl": "Vind mijn auto — [[action]]" + }, + "hint": { "en": "Find-my-car: flash the lights, honk the horn, or both.", + "no": "Finn bilen: blink med lys, tut i hornet, eller begge.", + "nl": "Vind-mijn-auto: knipper met lichten, toeter, of beide." }, + "args": [ + { + "type": "dropdown", + "name": "action", + "values": [ + { "id": "flash", "label": { "en": "Flash lights only", "no": "Kun blink", "nl": "Alleen knipperen" } }, + { "id": "honk", "label": { "en": "Honk only", "no": "Kun tut", "nl": "Alleen toeteren" } }, + { "id": "both", "label": { "en": "Honk and flash", "no": "Tut og blink", "nl": "Toeteren en knipperen" } } + ] + } + ] + }, + { + "id": "climate_start_simple", + "title": { "en": "Start climate (defaults)", "no": "Start klima (standard)", "nl": "Climate starten (standaard)" }, + "titleFormatted": { "en": "Start climate (defaults)", "no": "Start klima (standard)", "nl": "Climate starten (standaard)" }, + "hint": { "en": "Start parking climatization using the temperature and seat/steering defaults from device settings. Same behaviour as tapping the climate button in the Polestar app.", + "no": "Start klima med standard temperatur og sete/ratt-instillinger fra enhetsinnstillinger.", + "nl": "Start parkeer-climatisering met de standaard temperatuur en stoel/stuurwiel-instellingen uit device-settings." } + }, + { + "id": "climate_start", + "title": { "en": "Start climate", "no": "Start klima", "nl": "Climate starten" }, + "titleFormatted": { + "en": "Start climate to [[temp]]°C, seats FL [[seat_fl]] FR [[seat_fr]] RL [[seat_rl]] RR [[seat_rr]], steering [[wheel]]", + "no": "Start klima til [[temp]]°C, seter FV [[seat_fl]] FH [[seat_fr]] BV [[seat_rl]] BH [[seat_rr]], ratt [[wheel]]", + "nl": "Climate starten op [[temp]]°C, zittingen LV [[seat_fl]] RV [[seat_fr]] LA [[seat_rl]] RA [[seat_rr]], stuur [[wheel]]" + }, + "hint": { "en": "Start parking climatization with explicit temperature, per-seat heating and steering-wheel heating. Bypasses the device-settings defaults.", + "no": "Start klima med eksplisitt temperatur, setevarme og rattvarme. Overstyrer standard fra enhetsinnstillinger.", + "nl": "Start climatiseren met expliciete temperatuur, stoelverwarming per plek en stuurverwarming. Overschrijft defaults uit device-settings." }, + "args": [ + { "type": "number", "name": "temp", "min": 16, "max": 30, "step": 0.5, "placeholder": { "en": "°C", "no": "°C", "nl": "°C" } }, + { "type": "dropdown", "name": "seat_fl", "values": [ + { "id": "1", "label": { "en": "Seat FL off", "no": "Sete FV av", "nl": "Stoel LV uit" } }, + { "id": "2", "label": { "en": "Seat FL L1", "no": "Sete FV N1", "nl": "Stoel LV N1" } }, + { "id": "3", "label": { "en": "Seat FL L2", "no": "Sete FV N2", "nl": "Stoel LV N2" } }, + { "id": "4", "label": { "en": "Seat FL L3", "no": "Sete FV N3", "nl": "Stoel LV N3" } } + ]}, + { "type": "dropdown", "name": "seat_fr", "values": [ + { "id": "1", "label": { "en": "Seat FR off", "no": "Sete FH av", "nl": "Stoel RV uit" } }, + { "id": "2", "label": { "en": "Seat FR L1", "no": "Sete FH N1", "nl": "Stoel RV N1" } }, + { "id": "3", "label": { "en": "Seat FR L2", "no": "Sete FH N2", "nl": "Stoel RV N2" } }, + { "id": "4", "label": { "en": "Seat FR L3", "no": "Sete FH N3", "nl": "Stoel RV N3" } } + ]}, + { "type": "dropdown", "name": "seat_rl", "values": [ + { "id": "1", "label": { "en": "Seat RL off", "no": "Sete BV av", "nl": "Stoel LA uit" } }, + { "id": "2", "label": { "en": "Seat RL L1", "no": "Sete BV N1", "nl": "Stoel LA N1" } }, + { "id": "3", "label": { "en": "Seat RL L2", "no": "Sete BV N2", "nl": "Stoel LA N2" } }, + { "id": "4", "label": { "en": "Seat RL L3", "no": "Sete BV N3", "nl": "Stoel LA N3" } } + ]}, + { "type": "dropdown", "name": "seat_rr", "values": [ + { "id": "1", "label": { "en": "Seat RR off", "no": "Sete BH av", "nl": "Stoel RA uit" } }, + { "id": "2", "label": { "en": "Seat RR L1", "no": "Sete BH N1", "nl": "Stoel RA N1" } }, + { "id": "3", "label": { "en": "Seat RR L2", "no": "Sete BH N2", "nl": "Stoel RA N2" } }, + { "id": "4", "label": { "en": "Seat RR L3", "no": "Sete BH N3", "nl": "Stoel RA N3" } } + ]}, + { "type": "dropdown", "name": "wheel", "values": [ + { "id": "1", "label": { "en": "Steering off", "no": "Ratt av", "nl": "Stuur uit" } }, + { "id": "2", "label": { "en": "Steering L1", "no": "Ratt N1", "nl": "Stuur N1" } }, + { "id": "3", "label": { "en": "Steering L2", "no": "Ratt N2", "nl": "Stuur N2" } }, + { "id": "4", "label": { "en": "Steering L3", "no": "Ratt N3", "nl": "Stuur N3" } } + ]} + ] + }, + { + "id": "climate_stop", + "title": { "en": "Stop climate", "no": "Stopp klima", "nl": "Climate stoppen" }, + "titleFormatted": { "en": "Stop climate", "no": "Stopp klima", "nl": "Climate stoppen" }, + "hint": { "en": "Stop any running parking climatization session.", + "no": "Stopp pågående klimasesjon.", + "nl": "Stop een lopende climate-sessie." } + }, + { + "id": "windows_open", + "title": { "en": "Open all windows", "no": "Åpne alle vinduer", "nl": "Alle ramen openen" }, + "titleFormatted": { "en": "Open all windows", "no": "Åpne alle vinduer", "nl": "Alle ramen openen" }, + "hint": { "en": "Open all four side windows — e.g. to vent a hot cabin.", + "no": "Åpne alle fire sidevinduer.", + "nl": "Open alle vier zijramen — bv. om een hete cabine te luchten." } + }, + { + "id": "windows_close", + "title": { "en": "Close all windows", "no": "Lukk alle vinduer", "nl": "Alle ramen sluiten" }, + "titleFormatted": { "en": "Close all windows", "no": "Lukk alle vinduer", "nl": "Alle ramen sluiten" } + }, + { + "id": "get_location", + "title": { "en": "Get location", "no": "Hent plassering", "nl": "Locatie ophalen" }, + "titleFormatted": { "en": "Get location", "no": "Hent plassering", "nl": "Locatie ophalen" }, + "hint": { "en": "Fetches the last known position and exposes latitude, longitude, and a formatted location string as tokens for subsequent flow cards (e.g. push notifications with a map link).", + "no": "Henter siste kjente posisjon og eksponerer breddegrad, lengdegrad og en formatert tekststreng som tokens.", + "nl": "Haalt de laatst bekende positie op en levert breedtegraad, lengtegraad en een geformatteerde tekststring als tokens voor volgende flow-cards (bv. push met kaart-link)." }, + "tokens": [ + { "name": "latitude", "type": "number", "title": { "en": "Latitude", "no": "Breddegrad", "nl": "Breedtegraad" } }, + { "name": "longitude", "type": "number", "title": { "en": "Longitude", "no": "Lengdegrad", "nl": "Lengtegraad" } }, + { "name": "location", "type": "string", "title": { "en": "Location", "no": "Plassering", "nl": "Locatie" } } + ] + }, + { + "id": "set_amp_limit", + "title": { + "en": "Set charging amperage limit", + "no": "Sett maks ampere ved lading", + "nl": "Ampère-limiet instellen bij laden" + }, + "titleFormatted": { + "en": "Set amperage limit to [[amperage]] A", + "no": "Sett ampere-grense til [[amperage]] A", + "nl": "Ampère-limiet instellen op [[amperage]] A" + }, + "hint": { + "en": "Caps the AC charging current (6–32 A). Note: Polestar 4 currently reports this feature as unsupported.", + "no": "Maks vekselstrømsladestrøm (6–32 A). Merk: Polestar 4 rapporterer dette som ikke støttet.", + "nl": "Maximum AC-laadstroom (6–32 A). Let op: Polestar 4 meldt dat deze functie niet wordt ondersteund." + }, + "args": [ + { "type": "number", "name": "amperage", "min": 6, "max": 32, "step": 1, "placeholder": { "en": "A", "no": "A", "nl": "A" } } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/vehicle/driver.js b/drivers/vehicle/driver.js index d7c8022..48fbd2e 100644 --- a/drivers/vehicle/driver.js +++ b/drivers/vehicle/driver.js @@ -1,12 +1,63 @@ 'use strict'; const { Driver } = require('homey'); -const Polestar = require('@andysmithfal/polestar.js'); +const LegacyPolestar = require('../../clone_modules/polestar.js'); +const PolestarC3Compat = require('../../clone_modules/polestar-c3/compat'); const HomeyCrypt = require('../../lib/homeycrypt') +function Polestar(email, password, homey) { + const legacy = homey && homey.settings.get('c3_backend_disabled') === true; + const Client = legacy ? LegacyPolestar : PolestarC3Compat; + return new Client(email, password); +} + class Vehicle extends Driver { async onInit() { this.homey.app.log('Polestar Driver has been initialized', 'Polestar Driver', 'DEBUG'); + this._registerFlowCards(); + } + + _registerFlowCards() { + const actionRun = (method) => async (args) => { + if (!args.device) throw new Error('No device supplied to flow card'); + await args.device[method](args); + return true; + }; + + this.homey.flow.getActionCard('charge_start').registerRunListener(actionRun('chargeStart')); + this.homey.flow.getActionCard('charge_stop').registerRunListener(actionRun('chargeStop')); + this.homey.flow.getActionCard('set_target_soc').registerRunListener(actionRun('setTargetSoc')); + this.homey.flow.getActionCard('set_amp_limit').registerRunListener(actionRun('setAmpLimit')); + + this.homey.flow.getActionCard('lock_car').registerRunListener(actionRun('lockCar')); + this.homey.flow.getActionCard('unlock_car').registerRunListener(actionRun('unlockCar')); + this.homey.flow.getActionCard('unlock_trunk_action').registerRunListener(actionRun('unlockTrunkAction')); + this.homey.flow.getActionCard('honk_flash').registerRunListener(actionRun('honkFlashAction')); + this.homey.flow.getActionCard('climate_start').registerRunListener(actionRun('climateStartAction')); + this.homey.flow.getActionCard('climate_start_simple').registerRunListener(actionRun('climateStartSimpleAction')); + this.homey.flow.getActionCard('climate_stop').registerRunListener(actionRun('climateStopAction')); + this.homey.flow.getActionCard('windows_open').registerRunListener(actionRun('windowsOpenAction')); + this.homey.flow.getActionCard('windows_close').registerRunListener(actionRun('windowsCloseAction')); + this.homey.flow.getActionCard('get_location').registerRunListener(async (args) => { + if (!args.device) throw new Error('No device supplied to flow card'); + return args.device.getLocationForFlow(); + }); + + this.homey.flow.getConditionCard('target_soc_is').registerRunListener(async (args) => { + if (!args.device) return false; + const current = await args.device.getCurrentTargetSoc(); + return Number.isFinite(current) ? current >= args.level : false; + }); + this.homey.flow.getConditionCard('amp_limit_is').registerRunListener(async (args) => { + if (!args.device) return false; + const current = await args.device.getCurrentAmpLimit(); + return Number.isFinite(current) ? current >= args.amperage : false; + }); + this.homey.flow.getConditionCard('is_locked').registerRunListener(async (args) => { + return args.device ? args.device.isLocked() : false; + }); + + this.homey.app.log('Polestar flow cards registered', 'Polestar Driver', 'DEBUG'); } async onRepair(session, device) { @@ -34,7 +85,7 @@ class Vehicle extends Driver { this.homey.app.log('Password encrypted, credentials stored. Clear existing tokens.', 'Polestar Driver'); //Now we have the encrypted password stored we can start testing the info try { - var polestar = new Polestar(data.username, data.password); + var polestar = Polestar(data.username, data.password, this.homey); await polestar.login(); var testresult = await polestar.getVehicles(); this.homey.app.log('Credential test ok:', 'Polestar Driver', 'DEBUG', testresult); @@ -85,7 +136,7 @@ class Vehicle extends Driver { let PolestarPwd = await HomeyCrypt.decrypt(this.homey.settings.get('user_password'), PolestarUser); try { this.homey.app.log('Attempting to login to Polestar', 'Polestar Driver'); - var polestar = new Polestar(PolestarUser, PolestarPwd); + var polestar = Polestar(PolestarUser, PolestarPwd, this.homey); await polestar.login(); this.homey.app.log('Login successful, retrieving vehicles', 'Polestar Driver'); var vehiclelist = await polestar.getVehicles(); @@ -102,7 +153,7 @@ class Vehicle extends Driver { internalVehicleIdentifier: bev.internalVehicleIdentifier, modelName: bev.content.model.name, modelYear: bev.modelYear, - carImage: bev.content.images.studio.url, + carImage: bev.content.images?.studio?.url || null, deliveryDate: bev.deliveryDate, hasPerformancePackage: bev.hasPerformancePackage } @@ -131,23 +182,31 @@ class Vehicle extends Driver { session.setHandler('testlogin', async (data) => { this.homey.app.log('Test login and provide feedback, username length: ' + data.username.length + ' password length: ' + data.password.length, 'Polestar Driver'); - //Store the provided credentials, but hash and salt it first this.homey.settings.set('user_email', data.username); HomeyCrypt.crypt(data.password, data.username).then(cryptedpass => { - //this.homey.app.log(JSON.stringify(cryptedpass)); this.homey.settings.set('user_password', cryptedpass); }) - this.homey.app.log('Password encrypted, credentials stored. Clear existing tokens.', 'Polestar Driver'); - //Now we have the encrypted password stored we can start testing the info + this.homey.app.log('Password encrypted, credentials stored.', 'Polestar Driver'); + + const polestar = Polestar(data.username, data.password, this.homey); try { - var polestar = new Polestar(data.username, data.password); await polestar.login(); - var vehicles = await polestar.getVehicles(); - this.homey.app.log('Credential test ok:', 'Polestar Driver', 'DEBUG', vehicles); - return true; - } catch { - return false; + } catch (err) { + this.homey.app.log('Credential test failed:', 'Polestar Driver', 'ERROR', err); + return { ok: false, reason: 'login_failed' }; + } + let vehicles; + try { + vehicles = await polestar.getVehicles(); + } catch (err) { + this.homey.app.log('Retrieve vehicles failed:', 'Polestar Driver', 'ERROR', err); + return { ok: false, reason: 'login_failed' }; + } + this.homey.app.log('Credential test ok, vehicle count:', 'Polestar Driver', 'DEBUG', (vehicles || []).length); + if (!vehicles || vehicles.length === 0) { + return { ok: false, reason: 'no_vehicles' }; } + return { ok: true }; }); } diff --git a/drivers/vehicle/driver.settings.compose.json b/drivers/vehicle/driver.settings.compose.json new file mode 100644 index 0000000..8fe8399 --- /dev/null +++ b/drivers/vehicle/driver.settings.compose.json @@ -0,0 +1,125 @@ +[ + { + "id": "writes_enabled", + "type": "checkbox", + "label": { + "en": "Allow remote commands", + "no": "Tillat fjernkommandoer", + "nl": "Afstandscommando's toestaan" + }, + "value": true, + "hint": { + "en": "Master switch for write flows: start/stop charging, charge limit, amp limit, and (later) locks, windows, climate. Disable to make every write flow fail instantly.", + "no": "Hovedbryter for skriveflyter. Slå av for å få alle skriveflyter til å feile umiddelbart.", + "nl": "Hoofdschakelaar voor schrijfflows. Zet uit om alle schrijfflows direct te laten falen." + } + }, + { + "id": "target_soc_setting_type", + "type": "dropdown", + "label": { + "en": "Charge limit slot", + "no": "Ladegrense-slot", + "nl": "Laadlimiet-slot" + }, + "value": "1", + "hint": { + "en": "Which charge-limit slot the car should use when Homey writes a new value. Some Polestar models silently ignore writes to the wrong slot. Try 'Custom' if changes don't appear in the Polestar app.", + "no": "Hvilket ladegrenseslot bilen bruker når Homey skriver en ny verdi. Prøv 'Egendefinert' om endringer ikke vises i Polestar-appen.", + "nl": "Welk slot voor de laadlimiet de auto gebruikt als Homey een nieuwe waarde schrijft. Sommige Polestar-modellen negeren writes naar het verkeerde slot stil. Probeer 'Aangepast' als wijzigingen niet in de Polestar-app verschijnen." + }, + "values": [ + { "id": "1", "label": { "en": "Daily (default)", "no": "Daglig (standard)", "nl": "Dagelijks (standaard)" } }, + { "id": "2", "label": { "en": "Long trip", "no": "Lang tur", "nl": "Lange rit" } }, + { "id": "3", "label": { "en": "Custom", "no": "Egendefinert", "nl": "Aangepast" } }, + { "id": "0", "label": { "en": "Unspecified", "no": "Uspesifisert", "nl": "Niet gespecificeerd" } } + ] + }, + { + "id": "windows_supported", + "type": "checkbox", + "label": { + "en": "Windows remote control", + "no": "Fjernstyring av vinduer", + "nl": "Ramen op afstand bedienen" + }, + "value": true, + "hint": { + "en": "Leave checked unless your Polestar model responds with 'not supported' when trying Open/Close windows. Uncheck to hide the two window buttons from the device page. Auto-disabled if the backend returns UNIMPLEMENTED.", + "no": "La stå påskrudd med mindre bilen svarer 'ikke støttet' når du prøver Åpne/Lukk vinduer. Fjern haken for å skjule vinduskontrollene. Deaktiveres automatisk ved UNIMPLEMENTED.", + "nl": "Laat aangevinkt tenzij je Polestar 'niet ondersteund' meldt bij Openen/Sluiten van ramen. Uitvinken verbergt de raam-knoppen van de device-pagina. Automatisch uitgeschakeld als de backend UNIMPLEMENTED teruggeeft." + } + }, + { + "type": "group", + "label": { "en": "Climate defaults", "no": "Klima-standarder", "nl": "Climate standaardinstellingen" }, + "children": [ + { + "id": "climate_default_temp", + "type": "number", + "label": { "en": "Default temperature", "no": "Standard temperatur", "nl": "Standaard temperatuur" }, + "value": 21, "min": 16, "max": 30, "step": 0.5, "units": { "en": "°C" }, + "hint": { + "en": "Used when climate is started via the toggle, button, or onoff capability. The separate 'Climate target' tile overrides this for the next start.", + "no": "Brukes når klima startes via bryter, knapp eller onoff. 'Klima-mål'-flisen overstyrer dette ved neste start.", + "nl": "Gebruikt wanneer climate via de toggle, knop of onoff-capability wordt gestart. De 'Klimaatdoel'-tile overschrijft dit voor de eerstvolgende start." + } + }, + { + "id": "climate_seat_front_left", "type": "dropdown", + "label": { "en": "Front left seat heating", "no": "Setevarme forran venstre", "nl": "Zitverwarming linksvoor" }, + "value": "1", + "values": [ + { "id": "1", "label": { "en": "Off" } }, + { "id": "2", "label": { "en": "Level 1" } }, + { "id": "3", "label": { "en": "Level 2" } }, + { "id": "4", "label": { "en": "Level 3" } } + ] + }, + { + "id": "climate_seat_front_right", "type": "dropdown", + "label": { "en": "Front right seat heating", "no": "Setevarme forran høyre", "nl": "Zitverwarming rechtsvoor" }, + "value": "1", + "values": [ + { "id": "1", "label": { "en": "Off" } }, + { "id": "2", "label": { "en": "Level 1" } }, + { "id": "3", "label": { "en": "Level 2" } }, + { "id": "4", "label": { "en": "Level 3" } } + ] + }, + { + "id": "climate_seat_rear_left", "type": "dropdown", + "label": { "en": "Rear left seat heating", "no": "Setevarme bak venstre", "nl": "Zitverwarming linksachter" }, + "value": "1", + "values": [ + { "id": "1", "label": { "en": "Off" } }, + { "id": "2", "label": { "en": "Level 1" } }, + { "id": "3", "label": { "en": "Level 2" } }, + { "id": "4", "label": { "en": "Level 3" } } + ] + }, + { + "id": "climate_seat_rear_right", "type": "dropdown", + "label": { "en": "Rear right seat heating", "no": "Setevarme bak høyre", "nl": "Zitverwarming rechtsachter" }, + "value": "1", + "values": [ + { "id": "1", "label": { "en": "Off" } }, + { "id": "2", "label": { "en": "Level 1" } }, + { "id": "3", "label": { "en": "Level 2" } }, + { "id": "4", "label": { "en": "Level 3" } } + ] + }, + { + "id": "climate_steering_wheel", "type": "dropdown", + "label": { "en": "Steering wheel heating", "no": "Rattvarme", "nl": "Stuurwielverwarming" }, + "value": "1", + "values": [ + { "id": "1", "label": { "en": "Off" } }, + { "id": "2", "label": { "en": "Level 1" } }, + { "id": "3", "label": { "en": "Level 2" } }, + { "id": "4", "label": { "en": "Level 3" } } + ] + } + ] + } +] diff --git a/drivers/vehicle/pair/login.html b/drivers/vehicle/pair/login.html index c172f4f..aa2fd31 100644 --- a/drivers/vehicle/pair/login.html +++ b/drivers/vehicle/pair/login.html @@ -39,17 +39,23 @@

'username': usernameElement.val(), 'password': passwordElement.val() }).then(async function(result) { - //console.log('Testcomplete result: ' + result); - if (result) { - await Homey.emit('discover_vehicles').then(function(result) { - if (result) { - Homey.hideLoadingOverlay(); - Homey.nextView(); - } - }) + // Backwards compat: older driver returned a plain boolean. + const ok = result === true || (result && result.ok === true); + const reason = result && result.reason; + if (ok) { + await Homey.emit('discover_vehicles').then(function(result) { + if (result) { + Homey.hideLoadingOverlay(); + Homey.nextView(); + } + }); + return; + } + Homey.hideLoadingOverlay(); + if (reason === 'no_vehicles') { + Homey.alert("Login succeeded but no vehicles were returned for this account. Are you signed in with the right Polestar ID? Try again in a minute — Polestar's backend can briefly return an empty list."); } else { - Homey.hideLoadingOverlay(); - Homey.alert("Login validation failed, check your credentials or try again. Retrieving the token sometimes fails due to the website. If you are sure your credentials are fine just try a couple of times."); + Homey.alert("Login validation failed — check your e-mail address and password and try again."); } }); }); diff --git a/drivers/vehicle/repair/login.html b/drivers/vehicle/repair/login.html index b0552b6..6e05eea 100644 --- a/drivers/vehicle/repair/login.html +++ b/drivers/vehicle/repair/login.html @@ -40,7 +40,7 @@

Homey.done(); } else { Homey.hideLoadingOverlay(); - Homey.alert("Login validation failed, check your credentials or try again. Retrieving the token sometimes fails due to the website. If you are sure your credentials are fine just try a couple of times."); + Homey.alert("Login validation failed, check your credentials or try again."); } }); }); diff --git a/lib/homeycrypt.js b/lib/homeycrypt.js index 098d311..7bc8b37 100644 --- a/lib/homeycrypt.js +++ b/lib/homeycrypt.js @@ -16,8 +16,8 @@ async function getsalts() { var macbuffer = Buffer.from(mac.join(''), 'hex'); //console.log(mac); let salt = { - prepend: macbuffer.slice(3, 6), - append: macbuffer.slice(0, 3), + prepend: macbuffer.subarray(3, 6), + append: macbuffer.subarray(0, 3), iv: Buffer.concat([macbuffer, Buffer.from('00000000000000000000', 'hex')]) } return salt; diff --git a/locales/en.json b/locales/en.json index 1a0188d..d3fcd38 100644 --- a/locales/en.json +++ b/locales/en.json @@ -25,7 +25,10 @@ "title": "Account settings", "email": "Email address", "password": "Password", - "saveBtn": "Save" + "saveBtn": "Save", + "distanceUnit": "Distance unit", + "distanceUnitKm": "Kilometers (km)", + "distanceUnitMiles": "Miles (mi)" }, "debug": { "title": "Debug logs", diff --git a/locales/nl.json b/locales/nl.json index db8e2bf..8ae8c7b 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -25,7 +25,10 @@ "title": "Accountinstellingen", "email": "E-mailadres", "password": "Wachtwoord", - "saveBtn": "Opslaan" + "saveBtn": "Opslaan", + "distanceUnit": "Afstandseenheid", + "distanceUnitKm": "Kilometers (km)", + "distanceUnitMiles": "Mijlen (mi)" }, "debug": { "title": "Debug logger", diff --git a/locales/no.json b/locales/no.json index 94726fc..41a4112 100644 --- a/locales/no.json +++ b/locales/no.json @@ -25,7 +25,10 @@ "title": "Konto-innstillinger", "email": "Epost-adresse", "password": "Passord", - "saveBtn": "Lagre" + "saveBtn": "Lagre", + "distanceUnit": "Avstandsenhet", + "distanceUnitKm": "Kilometer (km)", + "distanceUnitMiles": "Miles (mi)" }, "debug": { "title": "Debug logger", diff --git a/package-lock.json b/package-lock.json index 7d05d2d..8f770d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,24 +25,24 @@ } }, "node_modules/@andysmithfal/polestar.js": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@andysmithfal/polestar.js/-/polestar.js-1.0.4.tgz", - "integrity": "sha512-lEeL9ivXWFjUkDxYc27Z5MsBB3Ej/l4V6YVV9pQxeRxnNR1w2v9NXMhJ8De+Z8i2OrJzjjEGJF7dYMCIbgDnoQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@andysmithfal/polestar.js/-/polestar.js-1.8.0.tgz", + "integrity": "sha512-I853lMExqjPlI5YmVx9kECERpK0RdUmx84J8iLgToCzaKpCSc55GMYX92VunlL5SI3mAhJLilQvTLs47/srAiw==", "dependencies": { - "axios": "^1.6.2" + "axios": "^1.7.7" } }, "node_modules/@tsconfig/node16": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz", - "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==", + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz", + "integrity": "sha512-9nTOUBn+EMKO6rtSZJk+DcqsfgtlERGT9XPJ5PRj/HNENPCBY1yu/JEj5wT6GLtbCLBO2k46SeXDaY0pjMqypw==", "dev": true }, "node_modules/@types/homey": { "name": "homey-apps-sdk-v3-types", - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/homey-apps-sdk-v3-types/-/homey-apps-sdk-v3-types-0.3.5.tgz", - "integrity": "sha512-AH7UPiPILITBmjDSAnupLp2kaBO9GuAj5y0hk9tLC2mO0AuOQuRYnbXIxrHOG3kium8m99FnS3BUHg0aY/SNCg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/homey-apps-sdk-v3-types/-/homey-apps-sdk-v3-types-0.3.12.tgz", + "integrity": "sha512-xr275VoF7FAiTi6PzElqHWpuGgSBY9hZFZsKeCTA4rluopf8sVk6zUPa+giSRSTiTrN3N3QjmHD5lUoNVwYd4g==", "dev": true, "dependencies": { "@types/node": "^14.14.20" @@ -55,12 +55,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.8.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", - "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "version": "20.17.50", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.50.tgz", + "integrity": "sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==", "dev": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/after": { @@ -79,11 +79,11 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.4.tgz", - "integrity": "sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -114,14 +114,28 @@ "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" }, - "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -164,19 +178,6 @@ "ms": "2.0.0" } }, - "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -185,10 +186,23 @@ "node": ">=0.4.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/engine.io-client": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.3.tgz", - "integrity": "sha512-qsgyc/CEhJ6cgMUwxRRtOndGVhIu5hpL5tR4umSpmX/MvkFoIxUTM7oFMDQumHNzlNLwSVy6qhstFPoWTf7dOw==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.4.tgz", + "integrity": "sha512-ydc8uuMMDxC5KCKNJN3zZKYJk2sgyTuTZQ7Aj1DJSsLKAcizA/PzWivw8fZMIjJVBo2CJOYzntv4FSjY/Lr//g==", "dependencies": { "component-emitter": "~1.3.0", "component-inherit": "0.0.3", @@ -198,15 +212,15 @@ "indexof": "0.0.1", "parseqs": "0.0.6", "parseuri": "0.0.6", - "ws": "~7.4.2", + "ws": "~7.5.10", "xmlhttprequest-ssl": "~1.6.2", "yeast": "0.1.2" } }, "node_modules/engine.io-client/node_modules/ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "engines": { "node": ">=8.3.0" }, @@ -235,10 +249,51 @@ "has-binary2": "~1.0.2" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -255,12 +310,13 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" }, "engines": { @@ -281,25 +337,46 @@ "integrity": "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ==" }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { + "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": { - "get-intrinsic": "^1.1.3" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -318,21 +395,10 @@ "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", "integrity": "sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==" }, - "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", - "dependencies": { - "get-intrinsic": "^1.2.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "engines": { "node": ">= 0.4" }, @@ -340,10 +406,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -352,9 +421,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -363,9 +432,9 @@ } }, "node_modules/homey-api": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/homey-api/-/homey-api-3.4.13.tgz", - "integrity": "sha512-lnVnp2gXCxDeGSYV7XFDXRz5bWm8L3Hv3UoKGCcjATCwlYaHg8T899YqTwYNzyl4jMQV5J7dcvCRNJR1kWgTOA==", + "version": "3.11.3", + "resolved": "https://registry.npmjs.org/homey-api/-/homey-api-3.11.3.tgz", + "integrity": "sha512-GwbQIdVJUTJDE4bH1zPEURKjlNOl766MTx+ALhqYg9NeHALbaQn2RCSmWxzhbSI+FiFiRaK2My8VJwEZx8UkVQ==", "dependencies": { "form-data": "^4.0.0", "node-fetch": "^2.6.7", @@ -385,6 +454,14 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -437,9 +514,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -460,11 +540,11 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -473,28 +553,69 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -519,9 +640,9 @@ } }, "node_modules/socket.io-parser": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.3.tgz", - "integrity": "sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.4.tgz", + "integrity": "sha512-z/pFQB3x+EZldRRzORYW1vwVO8m/3ILkswtnpoeU6Ve3cbMWkmHEWDAVJn4QJtchiiFTo5j7UG2QvwxvaA9vow==", "dependencies": { "component-emitter": "~1.3.0", "debug": "~3.1.0", @@ -539,9 +660,9 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, "node_modules/webidl-conversions": { @@ -559,9 +680,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "engines": { "node": ">=10.0.0" }, diff --git a/settings/index.html b/settings/index.html index 5f776ff..52404cb 100644 --- a/settings/index.html +++ b/settings/index.html @@ -148,6 +148,14 @@

data-i18n="Polestar2.settings.account.password"> +
+ + +
@@ -286,6 +294,7 @@

var emailElement = document.getElementById("email"); var passwordElement = document.getElementById("password"); + var distanceUnitElement = document.getElementById("distanceUnit"); var saveElement = document.getElementById("save"); Homey.get("user_email", function (err, username) { @@ -298,6 +307,11 @@

passwordElement.value = password; }); + Homey.get("distance_unit", function (err, distanceUnit) { + if (err) return Homey.alert(err); + distanceUnitElement.value = distanceUnit || 'km'; + }); + saveElement.addEventListener("click", function (e) { e.preventDefault(); @@ -307,7 +321,10 @@

Homey.set("user_password", passwordElement.value, function (err) { if (err) return Homey.alert(err); }); - Homey.alert(Homey.__({ en: "Settings saved", no: "Innstillinger lagret" })); + Homey.set("distance_unit", distanceUnitElement.value, function (err) { + if (err) return Homey.alert(err); + }); + Homey.alert(Homey.__({ en: "Settings saved", no: "Innstillinger lagret", nl: "Instellingen opgeslagen" })); }); } diff --git a/test-alternative-queries.js b/test-alternative-queries.js new file mode 100644 index 0000000..6c12111 --- /dev/null +++ b/test-alternative-queries.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node +'use strict'; + +const Polestar = require('./clone_modules/polestar.js/polestar.js'); +const axios = require('axios'); + +// Get credentials from command line arguments +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node test-alternative-queries.js '); + process.exit(1); +} + +const email = args[0]; +const password = args[1]; + +async function testAlternativeQueries() { + console.log('\n=== Testing Alternative Polestar API Queries ===\n'); + console.log('Based on research from pypolestar/polestar_api and evcc-io/evcc projects\n'); + + try { + const polestar = new Polestar(email, password); + await polestar.login(); + console.log('✓ Login successful!\n'); + + const vehicles = await polestar.getVehicles(); + await polestar.setVehicle(vehicles[0].vin); + + const token = polestar.getAccessToken(); + const vin = polestar.getVehicleVin(); + + console.log(`Testing with VIN: ${vin}\n`); + + const testQueries = [ + { + name: 'getOdometerData (OLD API)', + description: 'Old API query that might have more fields', + query: `query GetOdometerData($vin: String!) { + getOdometerData(vin: $vin) { + averageSpeedKmPerHour + eventUpdatedTimestamp { + iso + unix + } + odometerMeters + tripMeterAutomaticKm + tripMeterManualKm + } + }`, + variables: { vin } + }, + { + name: 'getBatteryData (OLD API)', + description: 'Old API query for battery with possibly more fields', + query: `query GetBatteryData($vin: String!) { + getBatteryData(vin: $vin) { + averageEnergyConsumptionKwhPer100Km + batteryChargeLevelPercentage + chargerConnectionStatus + chargingCurrentAmps + chargingPowerWatts + chargingStatus + estimatedChargingTimeMinutesToTargetDistance + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + estimatedDistanceToEmptyMiles + eventUpdatedTimestamp { + iso + unix + } + } + }`, + variables: { vin } + }, + { + name: 'getChargingConnectionStatus (OLD API)', + description: 'Specific query for charging connection', + query: `query GetChargingConnectionStatus($vin: String!) { + getChargingConnectionStatus(vin: $vin) { + chargerConnectionStatus + chargingPowerWatts + chargingCurrentAmps + chargingStatus + } + }`, + variables: { vin } + }, + { + name: 'carTelematics (OLD API)', + description: 'Original carTelematics query (non-V2)', + query: `query CarTelematics($vin: String!) { + carTelematics(vin: $vin) { + battery { + averageEnergyConsumptionKwhPer100Km + batteryChargeLevelPercentage + chargerConnectionStatus + chargingCurrentAmps + chargingPowerWatts + chargingStatus + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + } + odometer { + averageSpeedKmPerHour + odometerMeters + tripMeterAutomaticKm + tripMeterManualKm + } + } + }`, + variables: { vin } + }, + { + name: 'getConsumerCarByVin', + description: 'Get car details by VIN - might have location', + query: `query GetConsumerCarByVin($vin: String!) { + getConsumerCarByVin(vin: $vin) { + vin + internalVehicleIdentifier + location { + latitude + longitude + heading + } + position { + latitude + longitude + } + } + }`, + variables: { vin } + }, + { + name: 'vehicleLocation', + description: 'Direct location query', + query: `query VehicleLocation($vin: String!) { + vehicleLocation(vin: $vin) { + latitude + longitude + heading + timestamp + } + }`, + variables: { vin } + }, + { + name: 'getCarLocation', + description: 'Alternative location query', + query: `query GetCarLocation($vin: String!) { + getCarLocation(vin: $vin) { + latitude + longitude + heading + } + }`, + variables: { vin } + } + ]; + + const results = { + successful: [], + failed: [] + }; + + for (const testQuery of testQueries) { + console.log(`\n🔍 Testing: ${testQuery.name}`); + console.log(` ${testQuery.description}`); + console.log('─'.repeat(60)); + + try { + const response = await axios.post( + 'https://pc-api.polestar.com/eu-north-1/mystar-v2/', + { + query: testQuery.query, + operationName: testQuery.name.split(' ')[0], + variables: testQuery.variables + }, + { + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'pragma': 'no-cache' + } + } + ); + + if (response.data.errors) { + console.log('❌ Query failed'); + const errorMessages = response.data.errors.map(e => e.message); + errorMessages.forEach(msg => { + if (msg.includes('FieldUndefined')) { + // Extract field name from error + const match = msg.match(/Field '(\w+)'/); + if (match) { + console.log(` - Field not available: ${match[1]}`); + } else { + console.log(` - ${msg}`); + } + } else { + console.log(` - ${msg}`); + } + }); + results.failed.push({ + name: testQuery.name, + errors: errorMessages + }); + } else if (response.data.data) { + console.log('✅ SUCCESS! Data retrieved:'); + console.log(JSON.stringify(response.data.data, null, 2)); + results.successful.push({ + name: testQuery.name, + data: response.data.data + }); + } + } catch (error) { + console.log(`❌ Request failed: ${error.message}`); + results.failed.push({ + name: testQuery.name, + error: error.message + }); + } + } + + // Summary + console.log('\n\n' + '='.repeat(60)); + console.log('📊 RESEARCH SUMMARY'); + console.log('='.repeat(60)); + console.log(`✅ Successful queries: ${results.successful.length}`); + console.log(`❌ Failed queries: ${results.failed.length}`); + + if (results.successful.length > 0) { + console.log('\n🎉 WORKING ALTERNATIVE QUERIES FOUND:'); + results.successful.forEach(result => { + console.log(`\n ✅ ${result.name}`); + console.log(` Data: ${JSON.stringify(result.data, null, 2).substring(0, 200)}...`); + }); + + console.log('\n\n💡 RECOMMENDATION:'); + console.log(' Update polestar.js to use these working queries!'); + } else { + console.log('\n😞 NO ALTERNATIVE QUERIES WORK'); + console.log(' The Polestar API appears to have removed these endpoints.'); + console.log(' Only carTelematicsV2 with limited fields is available.'); + } + + console.log('\n📝 FINDINGS:'); + console.log(' - Location data: ' + (results.successful.some(r => r.name.toLowerCase().includes('location')) ? '✅ AVAILABLE' : '❌ NOT AVAILABLE')); + console.log(' - Charging Power/Amps: ' + (results.successful.some(r => JSON.stringify(r.data).includes('chargingPower') || JSON.stringify(r.data).includes('chargingCurrent')) ? '✅ AVAILABLE' : '❌ NOT AVAILABLE')); + console.log(' - Trip Meters: ' + (results.successful.some(r => JSON.stringify(r.data).includes('tripMeter')) ? '✅ AVAILABLE' : '❌ NOT AVAILABLE')); + console.log(' - Average Speed: ' + (results.successful.some(r => JSON.stringify(r.data).includes('averageSpeed')) ? '✅ AVAILABLE' : '❌ NOT AVAILABLE')); + + } catch (error) { + console.error('\n\x1b[31mError:\x1b[0m', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +testAlternativeQueries(); diff --git a/test-available-fields.js b/test-available-fields.js new file mode 100644 index 0000000..6eba196 --- /dev/null +++ b/test-available-fields.js @@ -0,0 +1,211 @@ +#!/usr/bin/env node +'use strict'; + +const Polestar = require('./clone_modules/polestar.js/polestar.js'); +const axios = require('axios'); + +// Get credentials from command line arguments +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node test-available-fields.js '); + process.exit(1); +} + +const email = args[0]; +const password = args[1]; + +async function testAvailableFields() { + console.log('\n=== Testing Currently Working Fields ===\n'); + + try { + const polestar = new Polestar(email, password); + await polestar.login(); + console.log('✓ Login successful!\n'); + + const vehicles = await polestar.getVehicles(); + await polestar.setVehicle(vehicles[0].vin); + + const token = polestar.getAccessToken(); + const vin = polestar.getVehicleVin(); + + console.log(`Testing with VIN: ${vin}\n`); + + // Test the current working query first + console.log('🔍 Testing: Current Working Battery Fields'); + console.log('─'.repeat(60)); + + const workingQuery = `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + battery { + vin + batteryChargeLevelPercentage + chargingStatus + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + estimatedDistanceToEmptyMiles + timestamp { seconds nanos } + } + odometer { + vin + odometerMeters + timestamp { seconds nanos } + } + health { + vin + brakeFluidLevelWarning + daysToService + distanceToServiceKm + engineCoolantLevelWarning + oilLevelWarning + serviceWarning + timestamp { seconds nanos } + } + } + }`; + + const response = await axios.post( + 'https://pc-api.polestar.com/eu-north-1/mystar-v2/', + { + query: workingQuery, + operationName: 'CarTelematicsV2', + variables: { vins: [vin] } + }, + { + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'pragma': 'no-cache' + } + } + ); + + if (response.data.errors) { + console.log('❌ Errors:', response.data.errors.map(e => e.message)); + } else { + console.log('✅ Success! Data retrieved:\n'); + console.log(JSON.stringify(response.data.data, null, 2)); + } + + // Now let's try to find any additional fields by testing common ones individually + console.log('\n\n🔍 Testing Individual Additional Fields'); + console.log('─'.repeat(60)); + + const fieldsToTest = [ + // Battery fields + { category: 'battery', field: 'batteryCapacityKwh', description: 'Total battery capacity' }, + { category: 'battery', field: 'currentPowerWatts', description: 'Current power draw' }, + { category: 'battery', field: 'chargeRate', description: 'Charging rate' }, + { category: 'battery', field: 'chargeLimit', description: 'Charge limit percentage' }, + { category: 'battery', field: 'chargingPower', description: 'Charging power' }, + { category: 'battery', field: 'timeToFullCharge', description: 'Time to full charge' }, + + // Odometer fields + { category: 'odometer', field: 'tripMeterKm', description: 'Trip meter distance' }, + { category: 'odometer', field: 'range', description: 'Remaining range' }, + + // Health fields + { category: 'health', field: 'tirePressureWarning', description: 'Tire pressure warning' }, + { category: 'health', field: 'batteryHealthPercentage', description: 'Battery health' }, + ]; + + const availableFields = { + battery: ['vin', 'batteryChargeLevelPercentage', 'chargingStatus', 'estimatedChargingTimeToFullMinutes', 'estimatedDistanceToEmptyKm', 'estimatedDistanceToEmptyMiles', 'timestamp'], + odometer: ['vin', 'odometerMeters', 'timestamp'], + health: ['vin', 'brakeFluidLevelWarning', 'daysToService', 'distanceToServiceKm', 'engineCoolantLevelWarning', 'oilLevelWarning', 'serviceWarning', 'timestamp'] + }; + + for (const testField of fieldsToTest) { + const testQuery = `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + ${testField.category} { + vin + ${testField.field} + } + } + }`; + + try { + const testResponse = await axios.post( + 'https://pc-api.polestar.com/eu-north-1/mystar-v2/', + { + query: testQuery, + operationName: 'CarTelematicsV2', + variables: { vins: [vin] } + }, + { + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'pragma': 'no-cache' + } + } + ); + + if (testResponse.data.errors) { + console.log(`❌ ${testField.category}.${testField.field} - Not available`); + } else { + console.log(`✅ ${testField.category}.${testField.field} - Available! (${testField.description})`); + availableFields[testField.category].push(testField.field); + + // Show the value if not null + const data = testResponse.data.data.carTelematicsV2[testField.category]; + if (data && data.length > 0 && data[0][testField.field] !== null) { + console.log(` Value: ${JSON.stringify(data[0][testField.field])}`); + } + } + } catch (error) { + console.log(`❌ ${testField.category}.${testField.field} - Error: ${error.message}`); + } + } + + // Summary + console.log('\n\n' + '='.repeat(60)); + console.log('📋 COMPLETE FIELD AVAILABILITY REPORT'); + console.log('='.repeat(60)); + + console.log('\n✅ AVAILABLE BATTERY FIELDS:'); + availableFields.battery.forEach(field => console.log(` - ${field}`)); + + console.log('\n✅ AVAILABLE ODOMETER FIELDS:'); + availableFields.odometer.forEach(field => console.log(` - ${field}`)); + + console.log('\n✅ AVAILABLE HEALTH FIELDS:'); + availableFields.health.forEach(field => console.log(` - ${field}`)); + + console.log('\n❌ NOT AVAILABLE (tested and failed):'); + console.log(' Battery:'); + console.log(' - chargingCurrentAmps'); + console.log(' - chargingPowerWatts'); + console.log(' - averageEnergyConsumptionKwhPer100Km'); + console.log(' - chargerConnectionStatus'); + console.log(' - estimatedChargingTimeMinutesToTargetDistance'); + console.log(' Odometer:'); + console.log(' - averageSpeedKmPerHour'); + console.log(' - tripMeterAutomaticKm'); + console.log(' - tripMeterManualKm'); + console.log(' Health:'); + console.log(' - washerFluidLevelWarning'); + console.log(' Telematics Categories:'); + console.log(' - location (GPS data)'); + console.log(' - climate (HVAC data)'); + console.log(' - locks (door locks)'); + console.log(' - windows (window status)'); + + console.log('\n📝 CONCLUSION:'); + console.log(' The Polestar API has been significantly simplified.'); + console.log(' Only basic telematics data is now available via CarTelematicsV2.'); + console.log(' Many fields that were previously available (or commented out in'); + console.log(' your code) are no longer supported by the API.'); + + } catch (error) { + console.error('\n\x1b[31mError:\x1b[0m', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +testAvailableFields(); diff --git a/test-c3-poc.js b/test-c3-poc.js new file mode 100644 index 0000000..ce6f60b --- /dev/null +++ b/test-c3-poc.js @@ -0,0 +1,90 @@ +'use strict'; + +/** + * Fase 0 PoC runner — tests OIDC login + C3 endpoint discovery + GetLatestBattery + * outside of Homey to validate the raw-HTTP2 gRPC stack. + * + * POLESTAR_EMAIL=you@example.com POLESTAR_PASSWORD='...' node test-c3-poc.js + * + * Optional: POLESTAR_VIN=... to pick a specific vehicle; otherwise the last one + * returned by the app-backend is used (newest on the account). + * Optional: POLESTAR_DEBUG=1 to print gRPC headers/trailers. + */ + +const { PolestarC3 } = require('./clone_modules/polestar-c3/client'); + +async function main() { + const email = process.env.POLESTAR_EMAIL; + const password = process.env.POLESTAR_PASSWORD; + if (!email || !password) { + console.error('Set POLESTAR_EMAIL and POLESTAR_PASSWORD env vars.'); + process.exit(2); + } + const forcedVin = process.env.POLESTAR_VIN || null; + + const client = new PolestarC3(email, password); + + console.log('[1/4] Logging in via OIDC/PKCE (client_id=lp8dyrd_10)…'); + await client.login(); + console.log(' OK — got access token (length', client._auth.accessToken.length, ').'); + console.log(' C3 endpoint:', client._endpoint); + + console.log('[2/4] Listing vehicles via app-backend GraphQL…'); + const vehicles = await client.listVehicles(); + if (!vehicles.length) { + console.error('No vehicles found on account.'); + process.exit(1); + } + for (const v of vehicles) { + console.log(' -', v.vin, v.registrationNo, v.content && v.content.model && v.content.model.name); + } + + const vin = forcedVin || vehicles[vehicles.length - 1].vin; + await client.setVehicle(vin); + console.log('[3/4] Using VIN:', vin); + + const debug = process.env.POLESTAR_DEBUG === '1'; + const replacer = (_k, v) => (typeof v === 'bigint' ? v.toString() : v); + + const step = async (label, fn) => { + console.log(label); + try { + const resp = await fn(); + console.log(' OK. Response:'); + console.log(JSON.stringify(resp, replacer, 2)); + return true; + } catch (err) { + console.error(' FAILED:', err.message); + process.exitCode = 1; + return false; + } + }; + + try { + await step('[4/10] BatteryService/GetLatestBattery (unary)…', + () => client.getLatestBattery({ debug })); + await step('[5/10] OdometerService/GetOdometer (server-stream, first frame)…', + () => client.getLatestOdometer({ debug })); + await step('[6/10] HealthService/GetHealth (server-stream, first frame)…', + () => client.getLatestHealth({ debug })); + await step('[7/10] ExteriorService/GetLatestExterior (unary)…', + () => client.getLatestExterior({ debug })); + await step('[8/10] ParkingClimatizationService/GetLatestParkingClimatization (unary)…', + () => client.getLatestClimate({ debug })); + await step('[9/10] TargetSocService/GetTargetSoc (chronos read)…', + async () => ({ target_level_pct: await client.getTargetSoc({ debug }) })); + await step('[10/11] AmpLimitService/GetAmpLimit (chronos read)…', + async () => ({ amperage_limit: await client.getAmpLimit({ debug }) })); + await step('[11/12] DtlInternetService/GetLastKnownLocation (unary)…', + () => client.getLastKnownLocation({ debug })); + await step('[12/12] OtaDiscoveryService/GetSoftwareInfo (server-stream, first frame)…', + () => client.getOtaSoftwareInfo({ debug })); + } finally { + client.close(); + } +} + +main().catch((err) => { + console.error('Fatal:', err); + process.exit(1); +}); diff --git a/test-queries.js b/test-queries.js new file mode 100644 index 0000000..6d38b18 --- /dev/null +++ b/test-queries.js @@ -0,0 +1,337 @@ +#!/usr/bin/env node +'use strict'; + +const Polestar = require('./clone_modules/polestar.js/polestar.js'); +const axios = require('axios'); + +// Get credentials from command line arguments +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node test-queries.js '); + process.exit(1); +} + +const email = args[0]; +const password = args[1]; + +async function testQueries() { + console.log('\n=== Polestar API Field Discovery ===\n'); + + let polestar; + let token; + let vin; + + try { + // Initialize and login + console.log('Logging in...'); + polestar = new Polestar(email, password); + await polestar.login(); + console.log('✓ Login successful!\n'); + + // Set a vehicle with better error handling + console.log('Getting vehicles...'); + let vehicles; + try { + vehicles = await polestar.getVehicles(); + console.log(`✓ Found ${vehicles.length} vehicle(s)\n`); + + if (vehicles.length > 0) { + console.log('Vehicle details:', JSON.stringify(vehicles[0], null, 2)); + await polestar.setVehicle(vehicles[0].vin); + console.log(`✓ Set vehicle: ${vehicles[0].vin}\n`); + } else { + console.error('No vehicles found in account'); + process.exit(1); + } + } catch (error) { + console.error('❌ Failed to get vehicles:', error.message); + console.error('This might be due to API changes. Trying to extract token anyway...\n'); + // Continue anyway - we can still test if we can get the token + } + + // Get the access token and VIN using public methods + console.log('Extracting access token and VIN...'); + + token = polestar.getAccessToken(); + vin = polestar.getVehicleVin() || (vehicles && vehicles.length > 0 ? vehicles[0].vin : null); + + if (!token) { + console.error('❌ Could not extract access token'); + console.error('Make sure the Polestar library has been updated with getAccessToken() method'); + process.exit(1); + } + + if (!vin) { + console.error('❌ Could not extract VIN'); + console.error('Please make sure you have at least one vehicle in your account'); + process.exit(1); + } + + console.log('✓ Access token obtained'); + console.log(`✓ Using VIN: ${vin}\n`); + console.log('Starting field discovery tests...\n'); + + // Test queries with different field combinations + const testCases = [ + { + name: 'Battery Data - Extended Fields', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + battery { + vin + batteryChargeLevelPercentage + chargingStatus + chargingCurrentAmps + chargingPowerWatts + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + estimatedDistanceToEmptyMiles + averageEnergyConsumptionKwhPer100Km + chargerConnectionStatus + estimatedChargingTimeMinutesToTargetDistance + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Odometer - Extended Fields', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + odometer { + vin + odometerMeters + averageSpeedKmPerHour + tripMeterAutomaticKm + tripMeterManualKm + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Health - Extended Fields', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + health { + vin + brakeFluidLevelWarning + daysToService + distanceToServiceKm + engineCoolantLevelWarning + oilLevelWarning + serviceWarning + washerFluidLevelWarning + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Location Data', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + location { + vin + latitude + longitude + heading + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Climate Data', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + climate { + vin + climateStatus + targetTemperatureCelsius + interiorTemperatureCelsius + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Door Lock Status', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + locks { + vin + lockStatus + engineHoodLockStatus + frontLeftDoorLockStatus + frontRightDoorLockStatus + rearLeftDoorLockStatus + rearRightDoorLockStatus + tailgateLockStatus + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'Windows Status', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + windows { + vin + frontLeftWindowOpen + frontRightWindowOpen + rearLeftWindowOpen + rearRightWindowOpen + sunroofOpen + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + }, + { + name: 'All Telematics Fields', + query: `query CarTelematicsV2($vins: [String!]!) { + carTelematicsV2(vins: $vins) { + battery { + vin + batteryChargeLevelPercentage + chargingStatus + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + timestamp { seconds nanos } + } + odometer { + vin + odometerMeters + timestamp { seconds nanos } + } + health { + vin + brakeFluidLevelWarning + daysToService + distanceToServiceKm + engineCoolantLevelWarning + oilLevelWarning + serviceWarning + timestamp { seconds nanos } + } + location { + vin + latitude + longitude + timestamp { seconds nanos } + } + climate { + vin + climateStatus + timestamp { seconds nanos } + } + locks { + vin + lockStatus + timestamp { seconds nanos } + } + windows { + vin + timestamp { seconds nanos } + } + } + }`, + variables: { vins: [vin] } + } + ]; + + const results = { + successful: [], + failed: [], + partiallySuccessful: [] + }; + + for (const testCase of testCases) { + console.log(`\n🔍 Testing: ${testCase.name}`); + console.log('─'.repeat(60)); + + try { + const response = await axios.post( + 'https://pc-api.polestar.com/eu-north-1/mystar-v2/', + { + query: testCase.query, + operationName: 'CarTelematicsV2', + variables: testCase.variables + }, + { + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'pragma': 'no-cache' + } + } + ); + + if (response.data.errors) { + console.log('❌ Query failed with errors:'); + response.data.errors.forEach(err => { + console.log(` - ${err.message}`); + }); + results.failed.push({ + name: testCase.name, + errors: response.data.errors + }); + } else if (response.data.data) { + console.log('✅ Query successful!'); + console.log('Response:'); + console.log(JSON.stringify(response.data.data, null, 2)); + results.successful.push({ + name: testCase.name, + data: response.data.data + }); + } + } catch (error) { + console.log('❌ Request failed:', error.message); + results.failed.push({ + name: testCase.name, + error: error.message + }); + } + } + + // Summary + console.log('\n\n' + '='.repeat(60)); + console.log('📊 SUMMARY'); + console.log('='.repeat(60)); + console.log(`✅ Successful queries: ${results.successful.length}`); + console.log(`❌ Failed queries: ${results.failed.length}`); + + if (results.successful.length > 0) { + console.log('\n✅ Working fields found:'); + results.successful.forEach(result => { + console.log(` - ${result.name}`); + }); + } + + if (results.failed.length > 0) { + console.log('\n❌ Failed queries:'); + results.failed.forEach(result => { + console.log(` - ${result.name}`); + }); + } + + } catch (error) { + console.error('\n\x1b[31mError:\x1b[0m', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +testQueries(); diff --git a/tmpclaude-71de-cwd b/tmpclaude-71de-cwd new file mode 100644 index 0000000..f74fc32 --- /dev/null +++ b/tmpclaude-71de-cwd @@ -0,0 +1 @@ +/c/Users/kaoh/OneDrive/Projecten/Polestar diff --git a/tmpclaude-c7cf-cwd b/tmpclaude-c7cf-cwd new file mode 100644 index 0000000..f74fc32 --- /dev/null +++ b/tmpclaude-c7cf-cwd @@ -0,0 +1 @@ +/c/Users/kaoh/OneDrive/Projecten/Polestar diff --git a/widgets/dashboard/api.js b/widgets/dashboard/api.js new file mode 100644 index 0000000..4fd78d3 --- /dev/null +++ b/widgets/dashboard/api.js @@ -0,0 +1,51 @@ +'use strict'; + +async function getVehicle({ homey, registration }) { + if (!homey) { + throw new Error('Missing Homey'); + } + + if (!registration) { + throw new Error('Missing Vehicle registration'); + } + + const driver = await homey.drivers.getDriver('vehicle'); + const vehicle = driver.getDevices().find(device => device.getData().registration === registration); + if (!vehicle) { + throw new Error('Vehicle Not Found'); + } + + return vehicle; +} + +module.exports = { + + async getVehicleStatus({ homey, query }) { + const { registration } = query; + const vehicle = await getVehicle({ homey, registration }); + + // Get distance unit setting (default to 'km') + const distanceUnit = homey.settings.get('distance_unit') || 'km'; + const unitLabel = distanceUnit === 'miles' ? 'MI' : 'KM'; + + return { + battery: vehicle.getCapabilityValue('measure_polestarBattery'), + connected: vehicle.getCapabilityValue('measure_vehicleConnected'), + charging: vehicle.getCapabilityValue('measure_vehicleChargeState'), + current: vehicle.getCapabilityValue('measure_current'), + power: vehicle.getCapabilityValue('measure_power'), + time_remaining: vehicle.getCapabilityValue('measure_vehicleChargeTimeRemaining'), + odometer: vehicle.getCapabilityValue('measure_vehicleOdometer'), + range: vehicle.getCapabilityValue('measure_vehicleRange'), + service: vehicle.getCapabilityValue('alarm_generic'), + distanceUnit: unitLabel, + }; + }, + + async getVehicles({ homey, body }){ + if (!homey) { + throw new Error('Missing Homey'); + } + return await homey.drivers.getDriver('vehicle').getDevices(); + } +}; diff --git a/widgets/dashboard/preview-dark.png b/widgets/dashboard/preview-dark.png new file mode 100644 index 0000000..3f0799d Binary files /dev/null and b/widgets/dashboard/preview-dark.png differ diff --git a/widgets/dashboard/preview-light.png b/widgets/dashboard/preview-light.png new file mode 100644 index 0000000..d163dc6 Binary files /dev/null and b/widgets/dashboard/preview-light.png differ diff --git a/widgets/dashboard/public/homey-logo.png b/widgets/dashboard/public/homey-logo.png new file mode 100644 index 0000000..7a67d04 Binary files /dev/null and b/widgets/dashboard/public/homey-logo.png differ diff --git a/widgets/dashboard/public/index.html b/widgets/dashboard/public/index.html new file mode 100644 index 0000000..262d2fd --- /dev/null +++ b/widgets/dashboard/public/index.html @@ -0,0 +1,194 @@ + + + + + + + +
+
+
+
charge
+
10%
+
+
+
4 A | 4 kW
+
360
+
+
+
range
+
200
+
KM
+
+
88888 KM
+ +
+
+
+ + + + \ No newline at end of file diff --git a/widgets/dashboard/widget.compose.json b/widgets/dashboard/widget.compose.json new file mode 100644 index 0000000..2d78692 --- /dev/null +++ b/widgets/dashboard/widget.compose.json @@ -0,0 +1,26 @@ +{ + "name": { + "en": "Vehicle Dashboard" + }, + "height": 188, + "transparent": true, + "settings": [ + { + "id": "device", + "type": "autocomplete", + "title": { + "en": "Vehicle" + } + } + ], + "api": { + "getVehicles":{ + "method": "GET", + "path": "/" + }, + "getVehicleStatus":{ + "method": "GET", + "path": "/status" + } + } +} \ No newline at end of file