Compare commits

..

8 Commits

Author SHA1 Message Date
ec5ae5eb99 new version
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-14 22:10:36 +02:00
b85ead2d04 changed key
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-14 21:52:45 +02:00
42a8968bdc Setting loglevel
All checks were successful
continuous-integration/drone/push Build is passing
Adding debug-log
2025-09-14 18:24:05 +02:00
a102863eff Setting endpoint default
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-12 17:51:10 +02:00
2e13948b3a Setting watchtower label
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-12 13:36:37 +02:00
538421e77e Adding StartupNotifier
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-12 13:24:35 +02:00
afcbcbaf12 .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-12 13:14:53 +02:00
e97864a251 Docker 2025-09-12 13:10:48 +02:00
8 changed files with 221 additions and 18 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
target/
.git/
.gitignore
.idea/
.vscode/
**/*.iml
secrets/
.env

29
.drone.yml Normal file
View 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
View 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

View File

@ -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: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.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;

View File

@ -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);
}
}
}

View File

@ -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));
}
}

View File

@ -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}