Here’s a small example of how I have used the Miele and HTTP bindings to automate calculation and execution of running the dishwasher during the night when the electricity prices are lowest. The implementation uses rates from https://www.energidataservice.dk/ provided in JSON format, so the concrete implementation is quite tailored to my needs.
The example could serve as inspiration though - and I could get lucky as well and receive feedback for improvements/other ways of doing things.
Prerequisites
For the concrete usage of the Miele binding, this pull request is needed in order to provide stable start/end times for scheduled program to avoid recalculations each minute:
Notes
I have everything implemented (for now) as DSL rules with limitations in terms of reuse etc. I would love to have implemented it using jsscripting, but unfortunately memory leaks are still preventing this. Additionally, the algorithms use brute force and there is room for optimizations.
Setup
http.things
Thing http:url:energidataservice "EnergiDataService" [ baseURL="https://api.energidataservice.dk/dataset/Elspotprices?start=utcnow&filter={\"PriceArea\":\"DK1\"}&columns=HourUTC,SpotPriceDKK", refresh=3600] {
Channels:
Type string : records
}
http.items
String EnergiDataService_Records { channel = "http:url:energidataservice:records" }
miele.items
Number Dishwasher_RawState "Raw state" (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:rawState"}
Number Dishwasher_RawProgram "Raw program" (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:rawProgram"}
DateTime Dishwasher_StartTime "Start time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:start"}
DateTime Dishwasher_EndTime "End time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher) ["Point"] {channel="miele:dishwasher:home:dishwasher:end"}
DateTime Dishwasher_CalculatedStartTime "Calculated start time [%1$td.%1tm.%1$tY %1$tR]" <time> (Dishwasher)
Number Dishwasher_CalculatedPrice "Calculated price [%.2f kr.]" <price> (Dishwasher)
Rules
First a generic rule which assumes a linear energy consumption during the program.
rule "Calculate dishwasher start time based on lowest price"
when
Item EnergiDataService_Records changed or
Item Dishwasher_StartTime changed or
Item Dishwasher_EndTime changed or
Item Dishwasher_RawState changed to 4 // Waiting to start
then
if (Dishwasher_RawState.state != 4) // Waiting to start
{
return
}
// Use dedicated rule for the ECO program.
if (Dishwasher_RawProgram.state == 28) // ECO
{
return
}
val DateTimeType startTimeState = (Dishwasher_StartTime.state as DateTimeType)
val DateTimeType endTimeState = (Dishwasher_EndTime.state as DateTimeType)
if (startTimeState == UNDEF || endTimeState == UNDEF)
{
return
}
val ZonedDateTime startTimeZoned = startTimeState.getZonedDateTime()
val ZonedDateTime endTimeZoned = endTimeState.getZonedDateTime()
if (endTimeZoned.isBefore(startTimeZoned))
{
return
}
val Instant programmedStart = startTimeZoned.toInstant()
val Instant programmedEnd = endTimeZoned.toInstant()
val Duration programDuration = Duration.between(programmedStart, programmedEnd)
logInfo("Dishwasher", "Starting rate/timeslot calculation for program "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString + " - "
+ LocalDateTime.ofInstant(programmedEnd, ZoneId.systemDefault()).toString)
var calcStart = now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES)
var calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
val String json = (EnergiDataService_Records.state as StringType).toString
val Number numRows = Integer::parseInt(transform("JSONPATH", "$.total", json))
var Instant cheapestStart
var Number cheapestPrice = 99999999
val HashMap<Instant, Number> priceMap = new HashMap()
for (var i = 0; i < numRows; i++)
{
val Instant hourStart = Instant.parse(transform("JSONPATH", "$.records[" + i + "].HourUTC", json) + "Z")
val Number hourPrice = Float::parseFloat(transform("JSONPATH", "$.records[" + i + "].SpotPriceDKK", json))
priceMap.put(hourStart, hourPrice)
}
while (calcEnd.compareTo(programmedEnd) <= 0)
{
var Instant hourStart = calcStart.truncatedTo(ChronoUnit.HOURS)
var Instant hourEnd = hourStart.plus(1, ChronoUnit.HOURS)
var Number price = 0
while (calcStart.isBefore(hourEnd) && calcEnd.isAfter(hourStart))
{
var Instant priceStart = hourStart
if (calcStart.isAfter(priceStart))
{
priceStart = calcStart
}
var Instant priceEnd = hourEnd
if (calcEnd.isBefore(priceEnd))
{
priceEnd = calcEnd
}
val Number hourPrice = priceMap.get(hourStart)
if (hourPrice === null)
{
logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString
+ ": missing rate for " + hourStart.toString)
return
}
var Number seconds = Duration.between(priceStart, priceEnd).getSeconds
price += (hourPrice * (seconds/3600))
hourStart = hourStart.plus(1, ChronoUnit.HOURS)
hourEnd = hourEnd.plus(1, ChronoUnit.HOURS)
}
if (price < cheapestPrice)
{
cheapestPrice = price
cheapestStart = calcStart
}
else if (price == cheapestPrice && calcStart.isBefore(cheapestStart))
{
cheapestStart = calcStart
}
// Now fast forward to the nearest start or end intersecting with a different hourly rate.
val Instant nextStartHour = calcStart.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)
val Instant nextEndHour = calcEnd.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)
if (calcStart.until(nextStartHour, ChronoUnit.SECONDS) < calcEnd.until(nextEndHour, ChronoUnit.SECONDS))
{
calcStart = nextStartHour
calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
}
else
{
calcEnd = nextEndHour
calcStart = calcEnd.minusSeconds(programDuration.getSeconds())
}
}
if (cheapestStart === null)
{
logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString)
return
}
logInfo("Dishwasher", "Finished rate/timeslot calculation with cheapest: " + cheapestPrice.toString + " with start "
+ LocalDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()).toString)
if (Duration.between(cheapestStart, programmedStart).toMinutes < 2)
{
logInfo("Dishwasher", "Best timeslot is same as already programmed, skipping override.")
}
else
{
val cheapestDt = new DateTimeType(ZonedDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()))
Dishwasher_CalculatedStartTime.postUpdate(cheapestDt)
}
end
Next rule is based on a mapped program where I have used the powerConsumption
channel to record 0.1 kWh data points to know exactly when the program uses the energy:
2022-09-16 02:01:31.420, 0.0
2022-09-16 02:37:13.428, 0.1
2022-09-16 02:45:05.096, 0.2
2022-09-16 02:49:15.462, 0.3
2022-09-16 02:51:35.576, 0.4
2022-09-16 02:55:28.788, 0.5
2022-09-16 03:31:06.045, 0.6
2022-09-16 04:12:54.429, 0.7
This makes it possible to perform a much more accurate calculation and better fit the program into the lowest price intervals.
The result will usually be quite different from the generic calculation, because the appliance actually doesn’t use any significant energy during the last 1:45 of the ECO program. Also, the concrete price is calculated, because it is known exactly how much energy is used by the program.
rule "Calculate dishwasher start time based on lowest accurate price (ECO)"
when
Item EnergiDataService_Records changed or
Item Dishwasher_StartTime changed or
Item Dishwasher_EndTime changed or
Item Dishwasher_RawState changed to 4 // Waiting to start
then
if (Dishwasher_RawState.state != 4) // Waiting to start
{
return
}
if (Dishwasher_RawProgram.state != 28) // ECO
{
return
}
// Timetable for program, 0.1 kWh consumed after each duration.
val ArrayList<Duration> phases = new ArrayList<Duration>()
phases.add(Duration.ofMinutes(37))
phases.add(Duration.ofMinutes(8))
phases.add(Duration.ofMinutes(4))
phases.add(Duration.ofMinutes(2))
phases.add(Duration.ofMinutes(4))
phases.add(Duration.ofMinutes(36))
phases.add(Duration.ofMinutes(41))
val DateTimeType startTimeState = (Dishwasher_StartTime.state as DateTimeType)
val DateTimeType endTimeState = (Dishwasher_EndTime.state as DateTimeType)
if (startTimeState == UNDEF || endTimeState == UNDEF)
{
return
}
val ZonedDateTime startTimeZoned = startTimeState.getZonedDateTime()
val ZonedDateTime endTimeZoned = endTimeState.getZonedDateTime()
if (endTimeZoned.isBefore(startTimeZoned))
{
return
}
val Instant programmedStart = startTimeZoned.toInstant()
val Instant programmedEnd = endTimeZoned.toInstant()
val Duration programDuration = Duration.between(programmedStart, programmedEnd)
logInfo("Dishwasher", "Starting rate/timeslot calculation for program "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString + " - "
+ LocalDateTime.ofInstant(programmedEnd, ZoneId.systemDefault()).toString)
var calcStart = now.toInstant().plusSeconds(60).truncatedTo(ChronoUnit.MINUTES)
var calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
val String json = (EnergiDataService_Records.state as StringType).toString
val Number numRows = Integer::parseInt(transform("JSONPATH", "$.total", json))
var Instant cheapestStart
var Number lowestPrice = 99999999
var Number highestPrice = 0
val HashMap<Instant, Number> priceMap = new HashMap()
for (var i = 0; i < numRows; i++)
{
val Instant hourStart = Instant.parse(transform("JSONPATH", "$.records[" + i + "].HourUTC", json) + "Z")
val Number hourPrice = Float::parseFloat(transform("JSONPATH", "$.records[" + i + "].SpotPriceDKK", json))
priceMap.put(hourStart, hourPrice)
}
while (calcEnd.compareTo(programmedEnd) <= 0)
{
var Instant calcPartStart = calcStart
var Instant calcPartEnd
var Number calcPrice = 0
var long minSecondsUntilNextHour = 60*60
for (var i = 0; i < phases.size(); i++)
{
val Duration current = phases.get(i)
calcPartEnd = calcPartStart.plus(current)
var Instant hourStart = calcPartStart.truncatedTo(ChronoUnit.HOURS)
var Instant hourEnd = hourStart.plus(1, ChronoUnit.HOURS)
// Get next intersection with hourly rate change.
var long secondsUntilNextHour = Duration.between(calcPartStart, hourEnd).getSeconds
if (secondsUntilNextHour < minSecondsUntilNextHour)
{
minSecondsUntilNextHour = secondsUntilNextHour
}
var Number partPrice = 0
while (calcPartStart.isBefore(hourEnd) && calcPartEnd.isAfter(hourStart))
{
val Number hourPrice = priceMap.get(hourStart)
if (hourPrice === null)
{
logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString
+ ": missing rate for " + hourStart.toString)
return
}
var Instant subPartStart = hourStart
if (calcPartStart.isAfter(subPartStart))
{
subPartStart = calcPartStart
}
var Instant subPartEnd = hourEnd
if (calcPartEnd.isBefore(subPartEnd))
{
subPartEnd = calcPartEnd
}
var Number subPartSeconds = Duration.between(subPartStart, subPartEnd).getSeconds
var Number partSeconds = Duration.between(calcPartStart, calcPartEnd).getSeconds
partPrice += (hourPrice / 10 * (subPartSeconds / partSeconds))
hourStart = hourStart.plus(1, ChronoUnit.HOURS)
hourEnd = hourEnd.plus(1, ChronoUnit.HOURS)
}
calcPrice += partPrice
calcPartStart = calcPartEnd
}
if (calcPrice < lowestPrice)
{
lowestPrice = calcPrice
cheapestStart = calcStart
}
else if (calcPrice == lowestPrice && calcStart.isBefore(cheapestStart))
{
cheapestStart = calcStart
}
if (calcPrice > highestPrice)
{
highestPrice = calcPrice
}
// Now fast forward to next hourly rate intersection.
calcStart = calcStart.plusSeconds(minSecondsUntilNextHour)
calcEnd = calcStart.plusSeconds(programDuration.getSeconds())
}
if (cheapestStart === null)
{
logWarn("Dishwasher", "Unable to perform rate/timeslot calculation for upcoming program: "
+ LocalDateTime.ofInstant(programmedStart, ZoneId.systemDefault()).toString)
return
}
// Convert to kr. and add VAT.
lowestPrice = lowestPrice * 1.25 / 1000
highestPrice = highestPrice * 1.25 / 1000
logInfo("Dishwasher", "Finished rate/timeslot calculation with lowest price " + lowestPrice.toString + ", starting "
+ LocalDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()).toString + ". Highest price was: " + highestPrice.toString)
if (Duration.between(cheapestStart, programmedStart).toMinutes < 2)
{
logInfo("Dishwasher", "Best timeslot is same as already programmed, skipping override.")
}
else
{
val cheapestDt = new DateTimeType(ZonedDateTime.ofInstant(cheapestStart, ZoneId.systemDefault()))
Dishwasher_CalculatedStartTime.postUpdate(cheapestDt)
Dishwasher_CalculatedPrice.postUpdate(lowestPrice)
}
end
And finally some rules for maintenance, notifications and triggering of the program at the right time:
rule "Send notification after dishwasher calculation"
when
Item Dishwasher_CalculatedStartTime changed
then
if (Dishwasher_CalculatedStartTime.state == NULL || Dishwasher_CalculatedStartTime.state == UNDEF)
{
return
}
val DateTimeType calculatedStartTimeState = (Dishwasher_CalculatedStartTime.state as DateTimeType)
val LocalDateTime calculatedStartTime = calculatedStartTimeState.getZonedDateTime().toLocalDateTime()
val DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm")
if (Dishwasher_CalculatedPrice.state != UNDEF)
{
sendPushMessage.apply("Dishwasher", "Program start changes to "
+ calculatedStartTime.format(formatter).toString + " after calculation of lowest price: "
+ String.format("%.2f", (Dishwasher_CalculatedPrice.state as Number).doubleValue).replace(".", ",")
+ " kr.")
}
else
{
sendPushMessage.apply("Dishwasher", "Program start changes to "
+ calculatedStartTime.format(formatter).toString + " after calculation of lowest price.")
}
end
rule "Start dishwasher based on calculated price"
when
Time is Dishwasher_CalculatedStartTime
then
if (Dishwasher_RawState.state != 4) // Waiting to start
{
return
}
if (Dishwasher_CalculatedStartTime.state == NULL || Dishwasher_CalculatedStartTime.state == UNDEF)
{
return
}
val ZonedDateTime calculatedStart = (Dishwasher_CalculatedStartTime.state as DateTimeType).getZonedDateTime()
if (now.isBefore(calculatedStart))
{
return
}
logInfo("Dishwasher", "Starting program ahead of time to get lower electricity price")
Dishwasher_Switch.sendCommand(ON)
end
rule "Clear calculated start time for dishwasher"
when
Item Dishwasher_RawState changed from 4 // Waiting to start
then
Dishwasher_CalculatedStartTime.postUpdate(UNDEF)
Dishwasher_CalculatedPrice.postUpdate(UNDEF)
end
One last note: When my Miele dishwasher is started this way, for some reason it will stay in the last phase: Finished with remaining 0:00. So it needs to be manually turned on and off again to get out of the state.