diff --git a/.gitignore b/.gitignore index 9154f4c..d15ac82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,104 @@ -# ---> Java +/target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + + +# Created by https://www.gitignore.io/api/git,java,maven,eclipse,windows + +### Eclipse ### + +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +### Eclipse Patch ### +# Eclipse Core +.project + +# JDT-specific (Eclipse Java Development Tools) +.classpath + +# Annotation Processing +.apt_generated + +.sts4-cache/ + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Java ### # Compiled class file *.class @@ -22,5 +122,80 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* -replay_pid* +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Some additional ignores (sort later) +*.DS_Store +*.sw? +.#* +*# +*~ +.classpath +.project +.settings +bin +build +target +dependency-reduced-pom.xml +*.sublime-* +/scratch +.gradle +README.html +*.iml +.idea +.exercism \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6c7dfd4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,85 @@ + + 4.0.0 + + + se.urmo + electricityalert + 0.0.1-SNAPSHOT + Electricity Price Alert + Alert system for electricity price using Telegram + jar + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.5 + + + + + 21 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.github.pengrad + java-telegram-bot-api + 9.2.0 + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/setup_project.sh b/setup_project.sh new file mode 100644 index 0000000..d86b9c9 --- /dev/null +++ b/setup_project.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +BASE_DIR="src/main" +JAVA_DIR="$BASE_DIR/java/se/urmo/electricityalert" +RES_DIR="$BASE_DIR/resources" + +# Create directory structure +mkdir -p "$JAVA_DIR/config" +mkdir -p "$JAVA_DIR/fetcher" +mkdir -p "$JAVA_DIR/alert" +mkdir -p "$JAVA_DIR/notifier" +mkdir -p "$JAVA_DIR/store" +mkdir -p "$JAVA_DIR/model" +mkdir -p "$RES_DIR" + +# Create empty Java files +touch "$JAVA_DIR/ElectricityAlertApplication.java" +touch "$JAVA_DIR/config/AppConfig.java" +touch "$JAVA_DIR/fetcher/PriceFetcherService.java" +touch "$JAVA_DIR/alert/AlertService.java" +touch "$JAVA_DIR/notifier/TelegramNotifier.java" +touch "$JAVA_DIR/store/InMemoryPriceStore.java" +touch "$JAVA_DIR/model/ElectricityPrice.java" + +# Create application.properties +touch "$RES_DIR/application.properties" + +echo "Project structure and base files created." + diff --git a/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java b/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java new file mode 100644 index 0000000..8cf3497 --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java @@ -0,0 +1,13 @@ +package se.urmo.electricityalert; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class ElectricityAlertApplication { + public static void main(String[] args) { + SpringApplication.run(ElectricityAlertApplication.class, args); + } +} diff --git a/src/main/java/se/urmo/electricityalert/alert/AlertService.java b/src/main/java/se/urmo/electricityalert/alert/AlertService.java new file mode 100644 index 0000000..6d101a2 --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/alert/AlertService.java @@ -0,0 +1,45 @@ +package se.urmo.electricityalert.alert; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import se.urmo.electricityalert.model.ElectricityPrice; +import se.urmo.electricityalert.notifier.TelegramNotifier; +import se.urmo.electricityalert.store.InMemoryPriceStore; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +public class AlertService { + private static final Logger log = LoggerFactory.getLogger(AlertService.class); + + private final InMemoryPriceStore store; + private final TelegramNotifier notifier; + private final BigDecimal threshold; + + public AlertService(InMemoryPriceStore store, + TelegramNotifier notifier, + @Value("${electricity.threshold}") BigDecimal threshold) { + this.store = store; + this.notifier = notifier; + this.threshold = threshold; + } + + // 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); + + 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); + } + } +} diff --git a/src/main/java/se/urmo/electricityalert/config/AppConfig.java b/src/main/java/se/urmo/electricityalert/config/AppConfig.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java new file mode 100644 index 0000000..0a1aa8f --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java @@ -0,0 +1,127 @@ +package se.urmo.electricityalert.fetcher; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.web.client.RestClient; +import se.urmo.electricityalert.fetcher.dto.PriceInfoDto; +import se.urmo.electricityalert.model.ElectricityPrice; +import se.urmo.electricityalert.store.InMemoryPriceStore; + +import java.time.OffsetDateTime; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PriceFetcherService { + + private static final Logger log = LoggerFactory.getLogger(PriceFetcherService.class); + + private final InMemoryPriceStore store; + private final RestClient client; + private final boolean fetchOnStartup; + + + // Your GraphQL query (exactly as you provided, compacted into a single string) + private static final String QUERY = """ + { viewer { homes { currentSubscription { priceInfo { + current { total energy tax startsAt } + today { total energy tax startsAt } + tomorrow { total energy tax startsAt } + } } } } } + """; + + public PriceFetcherService( + InMemoryPriceStore store, + @Value("${graphql.endpoint}") String endpoint, + @Value("${graphql.access-token}") String accessToken, + @Value("${fetch.on-startup:true}") boolean fetchOnStartup) { + this.store = store; + this.fetchOnStartup = fetchOnStartup; + this.client = RestClient.builder() + .baseUrl(endpoint) + .defaultHeader("Authorization", "Bearer " + accessToken) + .build(); + } + + /** Runs every day at 13:05. */ + @Scheduled(cron = "0 5 13 * * *") + public void fetchPrices() { + fetchPricesInternal(); + } + + /** One-time fetch after the app is fully started (beans initialized, web server up). */ + @EventListener(ApplicationReadyEvent.class) + public void onReadyFetchOnce() { + if (!fetchOnStartup) return; + log.info("Application started — performing one-time price fetch..."); + fetchPricesInternal(); + } + + /** Shared logic used by both the scheduled job and the startup hook. */ + private void fetchPricesInternal() { + try { + Map body = new HashMap<>(); + body.put("query", QUERY); + + PriceInfoDto result = client.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(PriceInfoDto.class); + + if (result == null || result.data() == null || result.data().viewer() == null + || result.data().viewer().homes() == null || result.data().viewer().homes().isEmpty()) { + log.warn("GraphQL response missing expected fields; nothing stored."); + return; + } + + var home = result.data().viewer().homes().get(0); + if (home == null || home.currentSubscription() == null + || home.currentSubscription().priceInfo() == null) { + log.warn("No priceInfo in response; nothing stored."); + return; + } + + var priceInfo = home.currentSubscription().priceInfo(); + int count = 0; + + // current + count += storePoints(List.of(priceInfo.current())); + // today + if (priceInfo.today() != null) count += storePoints(priceInfo.today()); + // tomorrow + if (priceInfo.tomorrow() != null) count += storePoints(priceInfo.tomorrow()); + + log.info("Stored {} price points from GraphQL.", count); + + } catch (Exception e) { + log.error("Failed to fetch/parse GraphQL prices", e); + } + } + + private int storePoints(List points) { + if (points == null || points.isEmpty()) return 0; + + int stored = 0; + 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) + + store.put(new ElectricityPrice(hourLocal, p.total())); + stored++; + } + return stored; + } +} diff --git a/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java b/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java new file mode 100644 index 0000000..579653b --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java @@ -0,0 +1,13 @@ +package se.urmo.electricityalert.fetcher.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record PriceInfoDto(Data data) { + public record Data(Viewer viewer) {} + public record Viewer(List homes) {} + public record Home(CurrentSubscription currentSubscription) {} + public record CurrentSubscription(PriceInfo priceInfo) {} + public record PriceInfo(PricePoint current, List today, List tomorrow) {} + public record PricePoint(BigDecimal total, BigDecimal energy, BigDecimal tax, String startsAt) {} +} diff --git a/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java b/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java new file mode 100644 index 0000000..9143755 --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java @@ -0,0 +1,7 @@ +package se.urmo.electricityalert.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record ElectricityPrice(LocalDateTime hour, BigDecimal price) { +} diff --git a/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java b/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java new file mode 100644 index 0000000..fb18592 --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java @@ -0,0 +1,22 @@ +package se.urmo.electricityalert.notifier; + +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.request.SendMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class TelegramNotifier { + private final TelegramBot bot; + private final String chatId; + + public TelegramNotifier(@Value("${telegram.bot.token}") String token, + @Value("${telegram.chat.id}") String chatId) { + this.bot = new TelegramBot(token); + this.chatId = chatId; + } + + public void send(String message) { + bot.execute(new SendMessage(chatId, message)); + } +} diff --git a/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java new file mode 100644 index 0000000..110fbc1 --- /dev/null +++ b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java @@ -0,0 +1,35 @@ +package se.urmo.electricityalert.store; + +import org.springframework.stereotype.Component; +import se.urmo.electricityalert.model.ElectricityPrice; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class InMemoryPriceStore { + private final Map prices = new ConcurrentHashMap<>(); + private final Set notified = ConcurrentHashMap.newKeySet(); + + public void put(ElectricityPrice price) { + prices.put(price.hour(), price); + } + + public ElectricityPrice get(LocalDateTime hour) { + return prices.get(hour); + } + + public Map all() { + return prices; + } + + public boolean alreadyNotified(LocalDateTime hour) { + return notified.contains(hour); + } + + public void markNotified(LocalDateTime hour) { + notified.add(hour); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..7a3e216 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# ? Telegram Bot +telegram.bot.token=${TELEGRAM_BOT_TOKEN} +telegram.chat.id=${TELEGRAM_CHAT_ID} + +# ? Electricity threshold (can also override via ENV if you want) +electricity.threshold=${ELECTRICITY_THRESHOLD:0.50} + +# ? GraphQL +graphql.endpoint=${GRAPHQL_ENDPOINT:https://provider.example/graphql} +graphql.access-token=${GRAPHQL_ACCESS_TOKEN} + +# ?? Behavior +fetch.on-startup=${FETCH_ON_STARTUP:true}