Skip to content

Instantly share code, notes, and snippets.

@fjallstrom
Last active December 22, 2024 09:54
Show Gist options
  • Save fjallstrom/e510edf6f53406b0954119bd3c40e0c3 to your computer and use it in GitHub Desktop.
Save fjallstrom/e510edf6f53406b0954119bd3c40e0c3 to your computer and use it in GitHub Desktop.
// version 2.2 20241220
// latest version complete rewrite due to memory issues on shelly. simpler config where just the top n hours are shaved off.
function padNumber(num) {
return num < 10 ? '0' + num : '' + num;
}
let CONFIG = {
NUMBER_OF_EXPENSIVE_HOURS: 8, // try to shave off this amount of expensive hours
PRICE_REGION: 'SE1',
UPDATE_INTERVAL: 60 * 1000,
RELAY_ID: 0,
NEXT_DAY_FETCH_HOUR: 17, // time of day to get prices for tomorrow
WEBHOOK_URL: '', // slack webhook url, can be empty
MIN_PRICE_THRESHOLD: 0.09 // always allow below this amount of sek
};
let priceControl = {
activePeriods: null,
nextDayPeriods: null,
lastRelayState: null,
updateTimer: null,
scheduleTimer: null,
nextDayTimer: null,
notifySlack: function(message) {
if (!CONFIG.WEBHOOK_URL) return;
Shelly.call(
"HTTP.POST",
{
url: CONFIG.WEBHOOK_URL,
body: JSON.stringify({ text: message }),
timeout: 15,
content_type: 'application/json'
},
null // Ta bort callback för att spara minne
);
},
processPrices: function(prices, formattedDate, isNextDay) {
try {
let blackoutHours = {};
let count = 0;
// Hitta högsta och lägsta pris först
let maxPrice = 0;
for (let i = 0; i < prices.length; i++) {
let price = prices[i].SEK_per_kWh;
if (price > maxPrice) maxPrice = price;
}
// Gå igenom timmarna i ordning efter pris
while (count < CONFIG.NUMBER_OF_EXPENSIVE_HOURS) {
let maxHour = -1;
let currentMax = 0;
for (let i = 0; i < prices.length; i++) {
let hour = parseInt(prices[i].time_start.substring(11,13));
let price = prices[i].SEK_per_kWh;
// Skippa om under priströskel eller redan vald
if (price < CONFIG.MIN_PRICE_THRESHOLD || blackoutHours[hour] !== undefined) {
continue;
}
// Kontrollera konsekutiva timmar
let prevHour = (hour - 1 + 24) % 24;
let nextHour = (hour + 1) % 24;
let isDaytime = hour >= 7 && hour < 23;
if (isDaytime) {
if (blackoutHours[prevHour] || blackoutHours[nextHour]) continue;
} else {
let consecutive = 1;
let checkHour = prevHour;
while (blackoutHours[checkHour] && checkHour >= 0 && checkHour < 7) {
consecutive++;
if (consecutive > 5) break;
checkHour = (checkHour - 1 + 24) % 24;
}
if (consecutive > 5) continue;
consecutive = 1;
checkHour = nextHour;
while (blackoutHours[checkHour] && (checkHour >= 23 || checkHour < 7)) {
consecutive++;
if (consecutive > 5) break;
checkHour = (checkHour + 1) % 24;
}
if (consecutive > 5) continue;
}
if (price > currentMax) {
currentMax = price;
maxHour = hour;
}
}
if (maxHour === -1) break;
blackoutHours[maxHour] = currentMax;
count++;
}
// Spara resultat
if (isNextDay) {
this.nextDayPeriods = blackoutHours;
} else {
this.activePeriods = blackoutHours;
}
// Inom processPrices funktionen, där meddelandet skapas:
let message = (isNextDay ? 'Morgondagens' : 'Dagens') + ' elpriser och strömstatus:\n';
for (let hour = 0; hour < 24; hour++) {
let price = prices[hour].SEK_per_kWh;
let status = blackoutHours[hour] ? "AV" : "PÅ";
let reason = "";
// Lägg till anledning
if (price < CONFIG.MIN_PRICE_THRESHOLD) {
reason = " [Under priströskel]";
} else if (status === "PÅ") {
if (hour >= 7 && hour < 23) {
// Kolla om vi har konsekutiva timmar som orsak
let prevHour = (hour - 1 + 24) % 24;
let nextHour = (hour + 1) % 24;
if (blackoutHours[prevHour] || blackoutHours[nextHour]) {
reason = " [Max 1h avstängning dagtid]";
} else {
reason = " [Billigare än andra timmar]";
}
} else {
// Nattetid
let consecutiveCount = 1;
let checkHour = (hour - 1 + 24) % 24;
while (blackoutHours[checkHour] && checkHour >= 0 && checkHour < 7) {
consecutiveCount++;
checkHour = (checkHour - 1 + 24) % 24;
}
if (consecutiveCount >= 5) {
reason = " [Max 5h avstängning nattetid]";
} else {
reason = " [Billigare än andra timmar]";
}
}
} else {
reason = " [Dyr timme]";
}
message += padNumber(hour) + ':00 - ' + price.toFixed(3) +
' SEK/kWh - Ström: ' + status + reason + '\n';
}
this.notifySlack(message);
} catch (e) {
print('Error i processPrices:', e.message);
this.notifySlack('Fel vid prisberäkning: ' + e.message);
}
},
checkAndSetRelay: function() {
let now = new Date();
let hour = now.getHours();
let shouldBeOn = !this.activePeriods[hour];
if (!shouldBeOn && hour < 4 && this.nextDayPeriods) {
shouldBeOn = !this.nextDayPeriods[hour];
}
if (this.lastRelayState !== shouldBeOn) {
let timeStr = padNumber(hour) + ':' + padNumber(now.getMinutes());
this.notifySlack(timeStr + ' - Varmvattenberedare ' + (shouldBeOn ? 'PÅ' : 'AV'));
this.lastRelayState = shouldBeOn;
}
Shelly.call("Switch.Set", { id: CONFIG.RELAY_ID, on: shouldBeOn }, null);
},
formatDate: function(date) {
return date.getFullYear() + '/' +
padNumber(date.getMonth() + 1) + '-' +
padNumber(date.getDate());
},
fetchPrices: function(date, isNextDay) {
let formattedDate = this.formatDate(date);
let url = 'https://www.elprisetjustnu.se/api/v1/prices/' +
formattedDate + '_' + CONFIG.PRICE_REGION + '.json';
Shelly.call(
"HTTP.GET",
{ url: url, timeout: 30 },
function(response, error_code, error_message) {
if (error_code !== 0) {
this.notifySlack('Fel vid hämtning av priser: ' + error_message);
return;
}
try {
let prices = JSON.parse(response.body);
this.processPrices(prices, formattedDate, isNextDay);
} catch (e) {
this.notifySlack('Fel vid parsning av prisdata: ' + e.message);
}
}.bind(this)
);
},
schedule: function() {
if (this.updateTimer) Timer.clear(this.updateTimer);
if (this.scheduleTimer) Timer.clear(this.scheduleTimer);
if (this.nextDayTimer) Timer.clear(this.nextDayTimer);
let now = new Date();
this.fetchPrices(now, false);
if (now.getHours() >= CONFIG.NEXT_DAY_FETCH_HOUR) {
let tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
this.nextDayTimer = Timer.set(5000, false, function() {
this.fetchPrices(tomorrow, true);
}.bind(this));
}
this.updateTimer = Timer.set(CONFIG.UPDATE_INTERVAL, true, function() {
this.checkAndSetRelay();
}.bind(this));
this.scheduleTimer = Timer.set(3600 * 1000, true, function() {
this.schedule();
}.bind(this));
}
};
priceControl.schedule();
let priceControl = {
activePeriods: [],
nextDayPeriods: [],
lastProcessedDate: null,
lastRelayState: null,
notifySlack: function(message) {
if (!CONFIG.WEBHOOK_URL) return;
Shelly.call(
"HTTP.POST",
{
url: CONFIG.WEBHOOK_URL,
body: JSON.stringify({ text: message }),
timeout: 15,
content_type: 'application/json'
},
function(response, error_code, error_message) {
if (error_code !== 0) {
print('Fel vid Slack-notifiering:', error_message);
}
}
);
},
processPrices: function(prices, formattedDate, isNextDay) {
print('Processar priser för ' + formattedDate);
try {
// Skapa prislista
let priceData = [];
for (let i = 0; i < prices.length; i++) {
let hour = (parseInt(prices[i].time_start.substring(11,13)) - 1 + 24) % 24;
priceData[i] = {
hour: hour,
price: prices[i].SEK_per_kWh
};
}
// Hitta de dyraste timmarna
let blackoutHours = {};
let selectedCount = 0;
let nightTimeHours = 0; // Räknare för timmar mellan 00-05
while (selectedCount < CONFIG.NUMBER_OF_EXPENSIVE_HOURS) {
let maxPrice = -1;
let maxHour = -1;
for (let i = 0; i < priceData.length; i++) {
let currentHour = priceData[i].hour;
// Skippa om timmen redan är vald
if (blackoutHours[currentHour] !== undefined) {
continue;
}
let isNightTime = currentHour >= 0 && currentHour < 5;
let prevHour = (currentHour - 1 + 24) % 24;
let nextHour = (currentHour + 1) % 24;
let twoHoursBefore = (currentHour - 2 + 24) % 24;
let twoHoursAfter = (currentHour + 2) % 24;
// Räkna antalet valda timmar mellan 00-05
let currentNightTimeHours = nightTimeHours;
if (isNightTime) {
currentNightTimeHours++;
}
// Kontrollera konsekutiva timmar
let wouldCreateThreeConsecutive = false;
if (!isNightTime) {
// Utanför 00-05, tillåt max 2 timmar i följd
wouldCreateThreeConsecutive =
(blackoutHours[prevHour] && blackoutHours[nextHour]) ||
(blackoutHours[prevHour] && blackoutHours[twoHoursBefore]) ||
(blackoutHours[nextHour] && blackoutHours[twoHoursAfter]);
} else {
// Under 00-05, tillåt upp till 5 timmar
if (currentNightTimeHours > 5) {
continue;
}
}
if ((!isNightTime && wouldCreateThreeConsecutive) || (isNightTime && currentNightTimeHours > 5)) {
continue;
}
if (priceData[i].price > maxPrice) {
maxPrice = priceData[i].price;
maxHour = currentHour;
}
}
// Om vi inte hittade någon giltig timme, avbryt
if (maxHour === -1) break;
// Lägg till den dyraste tillgängliga timmen
blackoutHours[maxHour] = maxPrice;
if (maxHour >= 0 && maxHour < 5) {
nightTimeHours++;
}
selectedCount++;
}
// Spara timmar
if (isNextDay) {
this.nextDayPeriods = blackoutHours;
} else {
this.activePeriods = blackoutHours;
}
// Skapa meddelande
let message = (isNextDay ? 'Morgondagens' : 'Dagens') + ' dyraste timmar (avstängd värme):\n';
for (let hour = 0; hour < 24; hour++) {
if (blackoutHours[hour] !== undefined) {
let nextHour = (hour + 1) % 24;
let priceText = blackoutHours[hour].toFixed(3);
message += padNumber(hour) + ':00-' + padNumber(nextHour) + ':00' +
' (' + priceText + ' SEK/kWh)\n';
}
}
print('Identifierade ' + Object.keys(blackoutHours).length + ' av ' + CONFIG.NUMBER_OF_EXPENSIVE_HOURS + ' dyra timmar');
this.notifySlack(message);
} catch (e) {
print('Error i processPrices: ' + e.message);
this.notifySlack('Fel vid prisberäkning: ' + e.message);
}
},
checkAndSetRelay: function() {
let now = new Date();
let currentHour = now.getHours();
let shouldBeOn = !this.activePeriods[currentHour];
if (!shouldBeOn && currentHour < 4 && this.nextDayPeriods) {
shouldBeOn = !this.nextDayPeriods[currentHour];
}
if (this.lastRelayState !== shouldBeOn) {
let timeStr = padNumber(now.getHours()) + ':' + padNumber(now.getMinutes());
let message = timeStr + ' - Varmvattenberedare ' + (shouldBeOn ? 'PÅ' : 'AV');
this.notifySlack(message);
this.lastRelayState = shouldBeOn;
}
Shelly.call("Switch.Set", {
id: CONFIG.RELAY_ID,
on: shouldBeOn
}, null);
print(now.getHours() + ':' + now.getMinutes() + ' - Relay set to: ' + shouldBeOn);
},
findPeriod: function(sortedPrices, usedHours, nextDayUsedHours, isNextDay) {
print('\nLooking for new period...');
for (let i = 0; i < sortedPrices.length; i++) {
let startHour = sortedPrices[i].hour;
if (usedHours[startHour]) continue;
for (let length = CONFIG.MAX_PERIOD_LENGTH; length >= CONFIG.MIN_PERIOD_LENGTH; length--) {
let valid = true;
let period = [];
for (let j = 0; j < length; j++) {
let hour = (startHour + j) % 24;
if (usedHours[hour]) {
valid = false;
print('Invalid: Hour ' + hour + ' already used');
break;
}
if (!isNextDay && hour < startHour) {
if (nextDayUsedHours[hour] || startHour - hour > 12) {
valid = false;
print('Invalid: Hour ' + hour + ' conflicts with next day schedule');
break;
}
}
for (let gap = 1; gap < CONFIG.MIN_GAP_BETWEEN_PERIODS; gap++) {
let beforeHour = (hour - gap + 24) % 24;
let afterHour = (hour + gap) % 24;
if (usedHours[beforeHour] || usedHours[afterHour]) {
valid = false;
print('Invalid: Gap requirement not met around hour ' + hour +
' (before: ' + beforeHour + ', after: ' + afterHour + ')');
break;
}
if (!isNextDay && afterHour < hour && nextDayUsedHours[afterHour]) {
valid = false;
print('Invalid: Gap requirement conflicts with next day schedule at hour ' + afterHour);
break;
}
}
if (!valid) break;
period.push(hour);
}
if (valid && period.length >= CONFIG.MIN_PERIOD_LENGTH) {
return period;
}
}
}
return null;
},
checkAndSetRelay: function() {
let now = new Date();
let currentHour = now.getHours();
let shouldBeOn = !this.activePeriods[currentHour];
if (!shouldBeOn && currentHour < 4 && this.nextDayPeriods) {
shouldBeOn = !this.nextDayPeriods[currentHour];
}
if (this.lastRelayState !== shouldBeOn) {
let timeStr = padNumber(now.getHours()) + ':' + padNumber(now.getMinutes());
let message = timeStr + ' - Varmvattenberedare ' + (shouldBeOn ? 'PÅ' : 'AV');
this.notifySlack(message);
this.lastRelayState = shouldBeOn;
}
Shelly.call("Switch.Set", {
id: CONFIG.RELAY_ID,
on: shouldBeOn
}, null);
print(now.getHours() + ':' + now.getMinutes() + ' - Relay set to: ' + shouldBeOn);
},
fetchPricesForDate: function(date, isNextDay) {
isNextDay = isNextDay || false;
var formattedDate = this.formatDate(date);
var url = 'https://www.elprisetjustnu.se/api/v1/prices/' + formattedDate + '_' + CONFIG.PRICE_REGION + '.json';
print('Fetching prices from: ' + url + (isNextDay ? ' for next day' : ''));
Shelly.call(
"HTTP.GET",
{
url: url,
timeout: 30
},
function(response, error_code, error_message) {
if (error_code !== 0) {
print('HTTP Error: ' + error_message);
this.notifySlack('Fel vid hämtning av priser: ' + error_message);
return;
}
try {
var prices = JSON.parse(response.body);
this.processPrices(prices, formattedDate, isNextDay);
} catch (e) {
print('Error parsing price data: ' + e.message);
this.notifySlack('Fel vid parsning av prisdata: ' + e.message);
}
}.bind(this)
);
},
formatDate: function(date) {
var year = date.getFullYear();
var month = '' + (date.getMonth() + 1);
var day = '' + date.getDate();
month = month.length < 2 ? '0' + month : month;
day = day.length < 2 ? '0' + day : day;
return year + '/' + month + '-' + day;
},
schedule: function() {
print("Starting price processing");
this.notifySlack("Startar om priskontroll");
// Rensa eventuella existerande timers
if (this.updateTimer) {
Timer.clear(this.updateTimer);
}
if (this.scheduleTimer) {
Timer.clear(this.scheduleTimer);
}
if (this.nextDayTimer) {
Timer.clear(this.nextDayTimer);
}
var now = new Date();
var tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
// Hämta dagens priser
this.fetchPricesForDate(now);
// Hämta morgondagens priser om det är efter CONFIG.NEXT_DAY_FETCH_HOUR
if (now.getHours() >= CONFIG.NEXT_DAY_FETCH_HOUR) {
this.nextDayTimer = Timer.set(5000, false, function() {
this.fetchPricesForDate(tomorrow, true);
}.bind(this));
}
// Sätt timer för relay-kontroll
this.updateTimer = Timer.set(CONFIG.UPDATE_INTERVAL, true, function() {
this.checkAndSetRelay();
}.bind(this));
// Sätt timer för nästa schedule-körning (en gång i timmen)
this.scheduleTimer = Timer.set(3600 * 1000, true, function() {
this.schedule();
}.bind(this));
}
};
priceControl.schedule();
@fjallstrom
Copy link
Author

Script för Shelly som ger elprisstyrning till osmart varmvattenberedare.
Config-delen i toppen gör att även andra osmarta saker bör gå att styra bort från att köras på dyra timmar.

@fjallstrom
Copy link
Author

image version 2.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment