Compare commits
8 Commits
2a7fe41b0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5ae5eb99 | |||
| b85ead2d04 | |||
| 42a8968bdc | |||
| a102863eff | |||
| 2e13948b3a | |||
| 538421e77e | |||
| afcbcbaf12 | |||
| e97864a251 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
.vscode/
|
||||
**/*.iml
|
||||
secrets/
|
||||
.env
|
||||
29
.drone.yml
Normal file
29
.drone.yml
Normal file
@ -0,0 +1,29 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: debug registry secrets
|
||||
image: alpine
|
||||
environment:
|
||||
USERNAME:
|
||||
from_secret: docker_username
|
||||
PASSWORD:
|
||||
from_secret: docker_password
|
||||
TEST:
|
||||
from_secret: test
|
||||
commands:
|
||||
- echo $TEST
|
||||
- echo $USERNAME
|
||||
- echo $PASSWORD
|
||||
|
||||
- name: build and push electricityalert image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: rubble.se:5000/urmo/electricityalert
|
||||
registry: rubble.se:5000
|
||||
tags: latest
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
# -------- Build stage --------
|
||||
FROM maven:3.9.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependencies
|
||||
COPY pom.xml .
|
||||
RUN mvn -q -DskipTests dependency:go-offline
|
||||
|
||||
# Build
|
||||
COPY src ./src
|
||||
RUN mvn -q -DskipTests package
|
||||
|
||||
# -------- Runtime stage --------
|
||||
FROM eclipse-temurin:21-jre-jammy
|
||||
|
||||
# Timezone & JVM defaults
|
||||
ENV TZ=Europe/Stockholm
|
||||
# Ensure JVM uses Stockholm time regardless of container TZ config
|
||||
ENV JAVA_TOOL_OPTIONS="-Duser.timezone=Europe/Stockholm"
|
||||
# Sensible container-aware memory settings (tweak as you like)
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75"
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -ms /bin/bash spring
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/electricityalert-*.jar /app/app.jar
|
||||
RUN chown -R spring:spring /app
|
||||
USER spring
|
||||
|
||||
# App port
|
||||
EXPOSE 8080
|
||||
|
||||
# Spring profile can be overridden at runtime: -e SPRING_PROFILES_ACTIVE=prod
|
||||
ENV SPRING_PROFILES_ACTIVE=prod
|
||||
|
||||
# Pass secrets as env vars (see your application.properties)
|
||||
# -e TELEGRAM_BOT_TOKEN=... -e TELEGRAM_CHAT_ID=... -e GRAPHQL_ACCESS_TOKEN=...
|
||||
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.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
@Service
|
||||
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 TelegramNotifier notifier;
|
||||
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,
|
||||
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.notifier = notifier;
|
||||
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)
|
||||
public void checkPrices() {
|
||||
LocalDateTime oneHourLater = LocalDateTime.now().withMinute(0).withSecond(0).plusHours(1);
|
||||
ElectricityPrice price = store.get(oneHourLater);
|
||||
LocalDateTime nowHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS);
|
||||
LocalDateTime nextHour = nowHour.plusHours(1);
|
||||
|
||||
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!",
|
||||
oneHourLater, price.price());
|
||||
notifier.send(msg);
|
||||
store.markNotified(oneHourLater);
|
||||
log.info("Sent alert: {}", msg);
|
||||
ElectricityPrice curr = store.get(nowHour); // may be null
|
||||
ElectricityPrice next = store.get(nextHour); // the hour we consider
|
||||
|
||||
boolean currentCheap = curr != null && curr.price().compareTo(threshold) < 0;
|
||||
boolean nextCheap = next != null && next.price().compareTo(threshold) < 0;
|
||||
|
||||
// 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.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -115,11 +116,10 @@ public class PriceFetcherService {
|
||||
for (var p : points) {
|
||||
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());
|
||||
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()));
|
||||
|
||||
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;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import se.urmo.electricityalert.model.ElectricityPrice;
|
||||
|
||||
@ -21,10 +22,6 @@ public class InMemoryPriceStore {
|
||||
return prices.get(hour);
|
||||
}
|
||||
|
||||
public Map<LocalDateTime, ElectricityPrice> all() {
|
||||
return prices;
|
||||
}
|
||||
|
||||
public boolean alreadyNotified(LocalDateTime hour) {
|
||||
return notified.contains(hour);
|
||||
}
|
||||
@ -32,4 +29,19 @@ public class InMemoryPriceStore {
|
||||
public void markNotified(LocalDateTime 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.token=${TELEGRAM_BOT_TOKEN}
|
||||
telegram.chat.id=${TELEGRAM_CHAT_ID:7321440614}
|
||||
@ -6,8 +7,13 @@ telegram.chat.id=${TELEGRAM_CHAT_ID:7321440614}
|
||||
electricity.threshold=${ELECTRICITY_THRESHOLD:0.50}
|
||||
|
||||
# ? 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}
|
||||
|
||||
# ?? Behavior
|
||||
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