Tulipa nysvättyä pörssisähkön hyväksikäytön kimpussa, rakensin oman automaation tällä yhdistelmällä:
- TeslaMate
- TeslaMateAPI
- Node-RED
- oma Node-RED flow, käyttäen NPM-kirjastoja nordpool ja linqjs
Tämä on hyvin varhainen versio, olen nyt muutamana yönä saanut auton ladattua tämän ohjaamana. Parannettavaa varmasti löytyy.
Toiminta-ajatus lyhykäisyydessään on
- Auto ladataan yöllä, aloitusaika on aikaisintaan kello 23:00
- Automaatio arvioi auton lataustilasta tarvittavan latausajan
- Automaatio hakee halvimman yhtäjaksoisen ajanjakson ko. lataukselle
- Jos auto on kotona, lataustöpseli on kiinni ja auto ei lataa (odottaa ajastetun latauksen käynnistystä), lähetetään autolle latauksen ajastuskomento hieman ennen kello 23:00
- Jos laskennassa tapahtuu virhe, lähetetään ajastuskomento, jossa ajastetaan kello 23:00
Tässä on kuva ko. Node-RED flow’sta
Tarvittava asennus
Toimiva TeslaMate, docker compose asennuksella. Omani pyörii Raspberry Pi 4:ssä
Node-RED, tähän ohjeeseen perustuen: https://docs.teslamate.org/docs/integrations/Node-RED
Node-RED osuus docker-compose.yml-tiedostosta:
node-red:
image: nodered/node-red:latest
restart: always
environment:
- TZ=Europe/Helsinki
volumes:
- node-red-data:/data
ports:
- 1881:1880
volumes:
node-red-data:
add-nr-modules.sh, lisätään nordpool ja js-linq
#!/bin/sh
MODULES="node-red-contrib-calc
node-red-contrib-simpletime
node-red-dashboard
node-red-node-email
node-red-contrib-telegrambot
node-red-node-ui-table
nordpool
js-linq"
set -x
for MODULE in $MODULES
do
docker compose exec -T node-red npm install --no-audit --no-update-notifier --no-fund --save --prefix=/data --production $MODULE
done
docker compose stop node-red
docker compose start node-red
TeslaMateAPI, tähän ohjeeseen perustuen: https://github.com/tobiasehlert/teslamateapi
TeslaMateAPI osuus docker-compose.yml-tiedostosta:
teslamateapi:
image: tobiasehlert/teslamateapi:latest
restart: always
depends_on:
- database
environment:
- ENCRYPTION_KEY=***
- DATABASE_USER=teslamate
- DATABASE_PASS=secret
- DATABASE_NAME=teslamate
- DATABASE_HOST=database
- MQTT_HOST=mosquitto
- TZ=Europe/Helsinki
- API_TOKEN=***
- ENABLE_COMMANDS=true
- COMMANDS_CHARGING=true
ports:
- 4001:8080
ENCRYPTION_KEY
on sama kuin teslamate ENCRYPTION_KEY
API_TOKEN
on salasana jota käytetään Node-RED flow’ssa komennon lähettämiseen autolle
Perusasennuksen testaus
Nyt pitäisi olla mahdollista lähettää autolle komentoja TeslaMateAPI’n välityksellä. TeslaMateAPI käyttää samaa Tesla-tilin tokenia jota myös TeslaMate käyttää. Testataan latauksen ajastusta kello 00:00 (Bearer ***
, tähän API_TOKEN
arvo tilalle), sekä ajastuksen poistoa (ko. komennot ajettu Node-RED kontin sisällä, ulkopuolella URL alkaisi http://localhost:4001/
):
$ curl -X POST -H "Authorization: Bearer ***" -d '{"enable": "true", "time": "0"}' http://teslamateapi:8080/api/v1/cars/1/command/set_scheduled_charging
{"response":{"reason":"","result":true}}
$ curl -X POST -H "Authorization: Bearer ***" -d '{"enable": "false", "time": "0"}' http://teslamateapi:8080/api/v1/cars/1/command/set_scheduled_charging
{"response":{"reason":"","result":true}}
Huomioitavaa
- Latausajastus tehdään vain jos auto on TeslaMate geolokaatiossa nimeltä ‘Koti’. Luo siis TeslaMate pääsivulta Georajaus nimellä ‘Koti’, joka siis rajaa sijainnin jossa autoa ladataan kotona
- Laita autoon ajastettu lataus päälle niin, että auto lataa vasta kello 22:55 jälkeen
- Flow’n toinen haara ‘Resume logging’ parantaa TeslaMaten latausten tallennuksen tarkkuutta. Ainakin oman auton (Model S, MCU1) tapauksessa jää usein ensimmäiset 5-15 minuuttia tallentamatta. ‘Resume logging’ lisäyksen jälkeen lataus tallentuu alusta asti
Node-RED flow JSON-muodossa
- Vasemmanpuoleisin import node kytkeytyy ‘Car Dashboards’ flow’n nodeen ‘topic-stored’. Mahtaakohan JSON import kytkeä tämän oikein?
- Nodessa ‘Schedule charge’, ‘Use authentication’ -> ‘Token’, arvon pitää olla
API_TOKEN
arvo
- Nodessa ‘Calculate charge start based on Nordpool prices’ lasketaan latausaika kaavalla
chargeHours = Math.ceil((90 - battery_level) / 7)
, eli jos lataustila nousee 7% tunnissa, kauanko kestää saavuttaa 90%
- Import tehdään Node-RED oikean yläkulman ‘hampurilaisvalikosta’
[
{
"id": "315d44a248a1f9b8",
"type": "tab",
"label": "Charge timing",
"disabled": false,
"info": "",
"env": []
},
{
"id": "ac396ed7751624cb",
"type": "link in",
"z": "315d44a248a1f9b8",
"name": "update-time",
"links": [
"8491f8d1.1365f8"
],
"x": 75,
"y": 160,
"wires": [
[
"02f83139a5504ea9"
]
]
},
{
"id": "fa4ef0e78c8a706e",
"type": "inject",
"z": "315d44a248a1f9b8",
"name": "Trigger at 22:55",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "55 22 * * *",
"once": false,
"onceDelay": "1",
"topic": "timestamp",
"payload": "",
"payloadType": "date",
"x": 190,
"y": 220,
"wires": [
[
"0cc902f1d835be41"
]
]
},
{
"id": "0cc902f1d835be41",
"type": "function",
"z": "315d44a248a1f9b8",
"name": "Calculate charge start based on Nordpool prices",
"func": "if (msg.topic != \"timestamp\") {\n context.set(msg.topic, msg.payload)\n return null\n}\n\ntry {\n let geofence = context.get(\"geofence\")\n let battery_level = context.get(\"battery_level\")\n let charging_state = context.get(\"charging_state\")\n\n if (geofence != \"Koti\" || charging_state != \"Stopped\") {\n node.status({ text: `Not plugged in at home: ${geofence}/${charging_state}` })\n return null\n }\n\n let chargeHours = Math.ceil((90 - battery_level) / 7)\n\n const nordpool = new Nordpool.Prices()\n\n let tomorrow = new Date()\n tomorrow.setDate(tomorrow.getDate() + 1)\n tomorrow.setHours(0, 0, 0)\n\n node.status({ text: \"Fetching Nordpool prices\" })\n\n const todayPrices = await nordpool.hourly({ area: 'FI' })\n const tomorrowPrices = await nordpool.hourly({ area: 'FI', from: tomorrow })\n const nordpoolPrices = Enumerable.from(todayPrices.concat(tomorrowPrices))\n\n node.status({ text: \"Nordpool prices fetched\" })\n\n let elevenPM = new Date()\n elevenPM.setHours(23, 0, 0, 0)\n\n const pricesSinceElevenPM = nordpoolPrices.where(i => new Date(i.date).getTime() >= elevenPM.getTime()).take(12)\n const hoursToConsider = pricesSinceElevenPM.take(pricesSinceElevenPM.count() - chargeHours)\n let startHourPrices = []\n\n hoursToConsider.toArray().forEach(\n (price, index) =>\n startHourPrices.push({ date: price.date, price: pricesSinceElevenPM.skip(index).take(chargeHours).sum(i => i.value) })\n )\n\n const cheapestStartHours = Enumerable.from(startHourPrices).orderBy(i => i.price)\n const startTime = new Date(cheapestStartHours.first().date)\n const startHour = new Date(cheapestStartHours.first().date).getHours()\n const startDelay = startTime.getTime() - new Date().getTime() - 60 * 1000\n node.status({ text: `Charge at: ${startHour.toString().padStart(2, '0')}:00 for ${chargeHours} hours`})\n\n return [\n {\n \"delay\": startDelay.toString()\n },\n {\n \"payload\": { \n \"enable\": \"true\",\n \"time\": startHour * 60\n }\n }\n ]\n} catch (e) {\n node.warn(`Exception: ${e}`);\n\n return [\n {\n \"delay\": (4 * 60 * 1000).toString()\n },\n {\n \"payload\": {\n \"enable\": \"true\",\n \"time\": 23 * 60\n }\n }\n ]\n}",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [
{
"var": "Enumerable",
"module": "linq-js"
},
{
"var": "Nordpool",
"module": "nordpool"
}
],
"x": 740,
"y": 160,
"wires": [
[
"6fdc375d860e060e"
],
[
"1682b39c452f558b",
"168677ceeae479e3"
]
]
},
{
"id": "02f83139a5504ea9",
"type": "function",
"z": "315d44a248a1f9b8",
"name": "Charge topics",
"func": "if (msg.topic == \"geofence\" ||\n msg.topic == \"battery_level\" || \n msg.topic == \"charging_state\") {\n return msg\n}\n\nreturn null",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 220,
"y": 160,
"wires": [
[
"d36041fe0b8eb83f",
"0cc902f1d835be41"
]
]
},
{
"id": "6fdc375d860e060e",
"type": "delay",
"z": "315d44a248a1f9b8",
"name": "Logging start delay",
"pauseType": "delayv",
"timeout": "5",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "second",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": false,
"allowrate": false,
"outputs": 1,
"x": 1070,
"y": 160,
"wires": [
[
"bbeed93578f5099c"
]
]
},
{
"id": "d36041fe0b8eb83f",
"type": "debug",
"z": "315d44a248a1f9b8",
"name": "",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "payload",
"targetType": "msg",
"statusVal": "$moment().tz('Europe/Helsinki').format('HH:mm:ss') & \": \" & msg.topic & \" = \" & msg.payload",
"statusType": "jsonata",
"x": 430,
"y": 100,
"wires": []
},
{
"id": "bbeed93578f5099c",
"type": "http request",
"z": "315d44a248a1f9b8",
"name": "Resume logging",
"method": "PUT",
"ret": "txt",
"paytoqs": "ignore",
"url": "http://teslamate:4000/api/car/1/logging/resume",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1280,
"y": 160,
"wires": [
[
"f5a2bcb47d483676"
]
]
},
{
"id": "168677ceeae479e3",
"type": "http request",
"z": "315d44a248a1f9b8",
"name": "Schedule charge",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "http://teslamateapi:8080/api/v1/cars/1/command/set_scheduled_charging",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "bearer",
"senderr": false,
"headers": [
{
"keyType": "Content-Type",
"keyValue": "",
"valueType": "other",
"valueValue": "application/json"
}
],
"x": 1070,
"y": 220,
"wires": [
[
"19c5c962102a6e62"
]
]
},
{
"id": "19c5c962102a6e62",
"type": "debug",
"z": "315d44a248a1f9b8",
"name": "",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "payload",
"targetType": "msg",
"statusVal": "$moment().tz('Europe/Helsinki').format('HH:mm:ss') & \": \" & msg.topic & \" = \" & msg.payload",
"statusType": "jsonata",
"x": 1270,
"y": 220,
"wires": []
},
{
"id": "1682b39c452f558b",
"type": "debug",
"z": "315d44a248a1f9b8",
"name": "",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "payload",
"targetType": "msg",
"statusVal": "$moment().tz('Europe/Helsinki').format('HH:mm:ss') & \": \" & msg.topic & \" = \" & msg.payload",
"statusType": "jsonata",
"x": 1050,
"y": 280,
"wires": []
},
{
"id": "f5a2bcb47d483676",
"type": "debug",
"z": "315d44a248a1f9b8",
"name": "",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "delay",
"targetType": "msg",
"statusVal": "$moment().tz('Europe/Helsinki').format('HH:mm:ss') & \": \" & msg.delay",
"statusType": "jsonata",
"x": 1470,
"y": 160,
"wires": []
}
]