new version
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Urban Modig
2025-09-14 22:10:36 +02:00
parent b85ead2d04
commit ec5ae5eb99
4 changed files with 115 additions and 22 deletions

View File

@ -1,6 +1,5 @@
package se.urmo.electricityalert.alert; package se.urmo.electricityalert.alert;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -12,37 +11,114 @@ import se.urmo.electricityalert.store.InMemoryPriceStore;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
@Slf4j
@Service @Service
public class AlertService { public class AlertService {
private static final Logger log = LoggerFactory.getLogger(AlertService.class);
private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");
private final InMemoryPriceStore store; private final InMemoryPriceStore store;
private final TelegramNotifier notifier; private final TelegramNotifier notifier;
private final BigDecimal threshold; private final BigDecimal threshold;
private final boolean quietEnabled;
private final LocalTime quietStart; // e.g. 22:00
private final LocalTime quietEnd; // e.g. 07:00 (wraps over midnight)
public AlertService(InMemoryPriceStore store, public AlertService(InMemoryPriceStore store,
TelegramNotifier notifier, TelegramNotifier notifier,
@Value("${electricity.threshold}") BigDecimal threshold) { @Value("${electricity.threshold}") BigDecimal threshold,
@Value("${alert.quiet.enabled:true}") boolean quietEnabled,
@Value("${alert.quiet.start:22:00}") String quietStartStr,
@Value("${alert.quiet.end:07:00}") String quietEndStr) {
this.store = store; this.store = store;
this.notifier = notifier; this.notifier = notifier;
this.threshold = threshold; this.threshold = threshold;
this.quietEnabled = quietEnabled;
this.quietStart = LocalTime.parse(quietStartStr);
this.quietEnd = LocalTime.parse(quietEndStr);
} }
// Check every 5 minutes /** Check every 5 minutes. */
@Scheduled(fixedRate = 300_000) @Scheduled(fixedRate = 300_000)
public void checkPrices() { public void checkPrices() {
LocalDateTime oneHourLater = LocalDateTime.now() LocalDateTime nowHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS);
.truncatedTo(ChronoUnit.HOURS) LocalDateTime nextHour = nowHour.plusHours(1);
.plusHours(1);
ElectricityPrice price = store.get(oneHourLater); ElectricityPrice curr = store.get(nowHour); // may be null
log.debug("Checking price at {} ({})", oneHourLater, price); ElectricityPrice next = store.get(nextHour); // the hour we consider
if (price != null && price.price().compareTo(threshold) < 0 && !store.alreadyNotified(oneHourLater)) {
String msg = String.format("⚡ In the next hour (%s) the price is %.2f SEK/kWh. Plug in the car!", boolean currentCheap = curr != null && curr.price().compareTo(threshold) < 0;
oneHourLater, price.price()); boolean nextCheap = next != null && next.price().compareTo(threshold) < 0;
notifier.send(msg);
store.markNotified(oneHourLater); // Only alert when crossing into a cheap window (edge-triggered)
log.info("Sent alert: {}", msg); if (!currentCheap && nextCheap) {
if (store.alreadyNotified(nextHour)) {
// Window was already notified (e.g., app restart later in same window)
return;
}
// Build the contiguous cheap window starting at nextHour
LocalDateTime start = nextHour;
LocalDateTime end = start;
LocalDateTime cursor = start;
while (true) {
ElectricityPrice p = store.get(cursor);
if (p == null || p.price().compareTo(threshold) >= 0) break;
end = cursor.plusHours(1);
cursor = cursor.plusHours(1);
}
// Respect quiet hours: if the window starts during quiet time, skip notify
// (You can change this to "defer to first non-quiet hour" if you wish.)
if (quietEnabled && isWithinQuietHours(start.toLocalTime(), quietStart, quietEnd)) {
log.info("Cheap window {}{} detected but within quiet hours; skipping alert.",
start.format(HHMM), end.format(HHMM));
// Still mark as notified to avoid waking you later for the same window.
store.markWindowNotified(start, end);
return;
}
// Find best (lowest) price hour inside the window for a nicer message
LocalDateTime bestHour = start;
BigDecimal bestPrice = store.get(start).price();
for (LocalDateTime t = start.plusHours(1); t.isBefore(end); t = t.plusHours(1)) {
ElectricityPrice p = store.get(t);
if (p != null && p.price().compareTo(bestPrice) < 0) {
bestPrice = p.price();
bestHour = t;
}
}
// Send alert
String msg = String.format(
"⚡ Cheap window %s%s (best at %s = %.3f SEK/kWh). Plug in the car!",
start.format(HHMM), end.format(HHMM), bestHour.format(HHMM), bestPrice
);
try {
notifier.send(msg);
log.info("Sent alert: {}", msg);
} catch (Exception e) {
log.warn("Failed to send alert message", e);
}
// Mark the whole window as notified to prevent repeats
store.markWindowNotified(start, end);
}
}
/** Handles ranges that may wrap over midnight (e.g., 22:0007:00). */
static boolean isWithinQuietHours(LocalTime t, LocalTime start, LocalTime end) {
if (start.equals(end)) return true; // whole day quiet
if (start.isBefore(end)) {
// Normal case: e.g., 21:0023:00
return !t.isBefore(start) && t.isBefore(end);
} else {
// Wrap-around: e.g., 22:0007:00
return !t.isBefore(start) || t.isBefore(end);
} }
} }
} }

View File

@ -15,6 +15,7 @@ import se.urmo.electricityalert.store.InMemoryPriceStore;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -115,11 +116,10 @@ public class PriceFetcherService {
for (var p : points) { for (var p : points) {
if (p == null || p.startsAt() == null || p.total() == null) continue; if (p == null || p.startsAt() == null || p.total() == null) continue;
// startsAt like: 2025-09-12T11:00:00.000+02:00
OffsetDateTime odt = OffsetDateTime.parse(p.startsAt()); OffsetDateTime odt = OffsetDateTime.parse(p.startsAt());
LocalDateTime hourLocal = odt.toLocalDateTime(); // interpret in local (offset already in string) LocalDateTime hourLocal = odt.toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
store.put(new ElectricityPrice(hourLocal, p.total())); store.put(new ElectricityPrice(hourLocal, p.total()));
stored++; stored++;
} }
return stored; return stored;

View File

@ -1,5 +1,6 @@
package se.urmo.electricityalert.store; package se.urmo.electricityalert.store;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import se.urmo.electricityalert.model.ElectricityPrice; import se.urmo.electricityalert.model.ElectricityPrice;
@ -21,10 +22,6 @@ public class InMemoryPriceStore {
return prices.get(hour); return prices.get(hour);
} }
public Map<LocalDateTime, ElectricityPrice> all() {
return prices;
}
public boolean alreadyNotified(LocalDateTime hour) { public boolean alreadyNotified(LocalDateTime hour) {
return notified.contains(hour); return notified.contains(hour);
} }
@ -32,4 +29,19 @@ public class InMemoryPriceStore {
public void markNotified(LocalDateTime hour) { public void markNotified(LocalDateTime hour) {
notified.add(hour); notified.add(hour);
} }
/** Mark a whole range [start, end) in hours as notified. */
public void markWindowNotified(LocalDateTime startInclusive, LocalDateTime endExclusive) {
for (LocalDateTime t = startInclusive; t.isBefore(endExclusive); t = t.plusHours(1)) {
notified.add(t);
}
}
/** Keep memory tidy: drop entries older than ~2 days. Runs at 00:02 daily. */
@Scheduled(cron = "0 2 0 * * *")
public void cleanup() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(2);
prices.keySet().removeIf(ts -> ts.isBefore(cutoff));
notified.removeIf(ts -> ts.isBefore(cutoff));
}
} }

View File

@ -12,3 +12,8 @@ graphql.access-token=${GRAPHQL_ACCESS_TOKEN}
# ?? Behavior # ?? Behavior
fetch.on-startup=${FETCH_ON_STARTUP:true} fetch.on-startup=${FETCH_ON_STARTUP:true}
# Quiet hours (local time)
alert.quiet.enabled=${ALERT_QUIET_ENABLED:true}
alert.quiet.start=${ALERT_QUIET_START:22:00}
alert.quiet.end=${ALERT_QUIET_END:07:00}