Compare commits
6 Commits
afcbcbaf12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5ae5eb99 | |||
| b85ead2d04 | |||
| 42a8968bdc | |||
| a102863eff | |||
| 2e13948b3a | |||
| 538421e77e |
@ -36,3 +36,5 @@ ENV SPRING_PROFILES_ACTIVE=prod
|
|||||||
# Pass secrets as env vars (see your application.properties)
|
# Pass secrets as env vars (see your application.properties)
|
||||||
# -e TELEGRAM_BOT_TOKEN=... -e TELEGRAM_CHAT_ID=... -e GRAPHQL_ACCESS_TOKEN=...
|
# -e TELEGRAM_BOT_TOKEN=... -e TELEGRAM_CHAT_ID=... -e GRAPHQL_ACCESS_TOKEN=...
|
||||||
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /app/app.jar"]
|
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /app/app.jar"]
|
||||||
|
|
||||||
|
LABEL com.centurylinklabs.watchtower.enable=true
|
||||||
|
|||||||
@ -11,35 +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;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AlertService {
|
public class AlertService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(AlertService.class);
|
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().withMinute(0).withSecond(0).plusHours(1);
|
LocalDateTime nowHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS);
|
||||||
ElectricityPrice price = store.get(oneHourLater);
|
LocalDateTime nextHour = nowHour.plusHours(1);
|
||||||
|
|
||||||
if (price != null && price.price().compareTo(threshold) < 0 && !store.alreadyNotified(oneHourLater)) {
|
ElectricityPrice curr = store.get(nowHour); // may be null
|
||||||
String msg = String.format("⚡ In the next hour (%s) the price is %.2f SEK/kWh. Plug in the car!",
|
ElectricityPrice next = store.get(nextHour); // the hour we consider
|
||||||
oneHourLater, price.price());
|
|
||||||
notifier.send(msg);
|
boolean currentCheap = curr != null && curr.price().compareTo(threshold) < 0;
|
||||||
store.markNotified(oneHourLater);
|
boolean nextCheap = next != null && next.price().compareTo(threshold) < 0;
|
||||||
log.info("Sent alert: {}", msg);
|
|
||||||
|
// Only alert when crossing into a cheap window (edge-triggered)
|
||||||
|
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:00–07: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:00–23:00
|
||||||
|
return !t.isBefore(start) && t.isBefore(end);
|
||||||
|
} else {
|
||||||
|
// Wrap-around: e.g., 22:00–07:00
|
||||||
|
return !t.isBefore(start) || t.isBefore(end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package se.urmo.electricityalert.notifier;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class StartupNotifier {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StartupNotifier.class);
|
||||||
|
|
||||||
|
private final TelegramNotifier telegramNotifier;
|
||||||
|
|
||||||
|
public StartupNotifier(TelegramNotifier telegramNotifier) {
|
||||||
|
this.telegramNotifier = telegramNotifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void notifyStartup() {
|
||||||
|
String msg = "🔄 Electricity Alert Service has restarted and is running.";
|
||||||
|
try {
|
||||||
|
telegramNotifier.send(msg);
|
||||||
|
log.info("Sent startup notification: {}", msg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to send startup notification", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
logging.level.root=DEBUG
|
||||||
# ? Telegram Bot
|
# ? Telegram Bot
|
||||||
telegram.bot.token=${TELEGRAM_BOT_TOKEN}
|
telegram.bot.token=${TELEGRAM_BOT_TOKEN}
|
||||||
telegram.chat.id=${TELEGRAM_CHAT_ID:7321440614}
|
telegram.chat.id=${TELEGRAM_CHAT_ID:7321440614}
|
||||||
@ -6,8 +7,13 @@ telegram.chat.id=${TELEGRAM_CHAT_ID:7321440614}
|
|||||||
electricity.threshold=${ELECTRICITY_THRESHOLD:0.50}
|
electricity.threshold=${ELECTRICITY_THRESHOLD:0.50}
|
||||||
|
|
||||||
# ? GraphQL
|
# ? GraphQL
|
||||||
graphql.endpoint=${GRAPHQL_ENDPOINT:https://provider.example/graphql}
|
graphql.endpoint=${GRAPHQL_ENDPOINT:https://api.tibber.com/v1-beta/gql}
|
||||||
graphql.access-token=${GRAPHQL_ACCESS_TOKEN}
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user