Last active
December 22, 2024 09:54
-
-
Save fjallstrom/e510edf6f53406b0954119bd3c40e0c3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.