initial commit
This commit is contained in:
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<PriceInfoDto.PricePoint> 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;
|
||||
}
|
||||
}
|
||||
@ -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<Home> homes) {}
|
||||
public record Home(CurrentSubscription currentSubscription) {}
|
||||
public record CurrentSubscription(PriceInfo priceInfo) {}
|
||||
public record PriceInfo(PricePoint current, List<PricePoint> today, List<PricePoint> tomorrow) {}
|
||||
public record PricePoint(BigDecimal total, BigDecimal energy, BigDecimal tax, String startsAt) {}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package se.urmo.electricityalert.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ElectricityPrice(LocalDateTime hour, BigDecimal price) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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<LocalDateTime, ElectricityPrice> prices = new ConcurrentHashMap<>();
|
||||
private final Set<LocalDateTime> notified = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public void put(ElectricityPrice price) {
|
||||
prices.put(price.hour(), price);
|
||||
}
|
||||
|
||||
public ElectricityPrice get(LocalDateTime hour) {
|
||||
return prices.get(hour);
|
||||
}
|
||||
|
||||
public Map<LocalDateTime, ElectricityPrice> all() {
|
||||
return prices;
|
||||
}
|
||||
|
||||
public boolean alreadyNotified(LocalDateTime hour) {
|
||||
return notified.contains(hour);
|
||||
}
|
||||
|
||||
public void markNotified(LocalDateTime hour) {
|
||||
notified.add(hour);
|
||||
}
|
||||
}
|
||||
13
src/main/resources/application.properties
Normal file
13
src/main/resources/application.properties
Normal file
@ -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}
|
||||
Reference in New Issue
Block a user