something's working...

This commit is contained in:
Shautvast 2025-10-07 21:31:35 +02:00
parent 5eba13e51e
commit 7d298eb48b
12 changed files with 2486 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.env

1969
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "solarmon"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.6"
chrono = "0.4.42"
dotenv = "0.15.0"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.47", features = ["full"] }
tower-http = { version = "0.6", features = ["fs"] }
tower = "0.5.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
anyhow = "1.0"
tower-livereload = "0.9.6"

39
src/details.json Normal file
View file

@ -0,0 +1,39 @@
{
"details": {
"id": 2193535,
"name": "S. Hautvast",
"accountId": 30166,
"status": "Active",
"peakPower": 3.51,
"lastUpdateTime": "2025-10-07",
"installationDate": "2021-04-13",
"ptoDate": null,
"notes": "",
"type": "Optimizers & Inverters",
"location": {
"country": "Netherlands",
"city": "Amsterdam",
"address": "A. Moenstraat, 22",
"address2": "",
"zip": "1022 KJ",
"timeZone": "Europe/Amsterdam",
"countryCode": "NL",
"latitude": "52.4069905",
"longitude": "4.932790799999999"
},
"primaryModule": {
"manufacturerName": "Hyundai Heavy Industries",
"modelName": "HiE-S390VG",
"maximumPower": 0.0,
"temperatureCoef": 0.0
},
"alertQuantity": 0,
"highestImpact": "0",
"uris": {
"DETAILS": "/site/2193535/details",
"DATA_PERIOD": "/site/2193535/dataPeriod",
"OVERVIEW": "/site/2193535/overview"
},
"publicSettings": { "isPublic": null }
}
}

104
src/energy.json Normal file
View file

@ -0,0 +1,104 @@
{
"energy": {
"timeUnit": "QUARTER_OF_AN_HOUR",
"unit": "Wh",
"values": [
{ "date": "2025-10-06 00:00:00", "value": null },
{ "date": "2025-10-06 00:15:00", "value": null },
{ "date": "2025-10-06 00:30:00", "value": null },
{ "date": "2025-10-06 00:45:00", "value": null },
{ "date": "2025-10-06 01:00:00", "value": null },
{ "date": "2025-10-06 01:15:00", "value": null },
{ "date": "2025-10-06 01:30:00", "value": null },
{ "date": "2025-10-06 01:45:00", "value": null },
{ "date": "2025-10-06 02:00:00", "value": null },
{ "date": "2025-10-06 02:15:00", "value": null },
{ "date": "2025-10-06 02:30:00", "value": null },
{ "date": "2025-10-06 02:45:00", "value": null },
{ "date": "2025-10-06 03:00:00", "value": null },
{ "date": "2025-10-06 03:15:00", "value": null },
{ "date": "2025-10-06 03:30:00", "value": null },
{ "date": "2025-10-06 03:45:00", "value": null },
{ "date": "2025-10-06 04:00:00", "value": null },
{ "date": "2025-10-06 04:15:00", "value": null },
{ "date": "2025-10-06 04:30:00", "value": null },
{ "date": "2025-10-06 04:45:00", "value": null },
{ "date": "2025-10-06 05:00:00", "value": null },
{ "date": "2025-10-06 05:15:00", "value": null },
{ "date": "2025-10-06 05:30:00", "value": null },
{ "date": "2025-10-06 05:45:00", "value": null },
{ "date": "2025-10-06 06:00:00", "value": null },
{ "date": "2025-10-06 06:15:00", "value": null },
{ "date": "2025-10-06 06:30:00", "value": null },
{ "date": "2025-10-06 06:45:00", "value": null },
{ "date": "2025-10-06 07:00:00", "value": 0.0 },
{ "date": "2025-10-06 07:15:00", "value": null },
{ "date": "2025-10-06 07:30:00", "value": null },
{ "date": "2025-10-06 07:45:00", "value": 0.0 },
{ "date": "2025-10-06 08:00:00", "value": 0.0 },
{ "date": "2025-10-06 08:15:00", "value": 0.0 },
{ "date": "2025-10-06 08:30:00", "value": 0.0 },
{ "date": "2025-10-06 08:45:00", "value": 0.0 },
{ "date": "2025-10-06 09:00:00", "value": 0.0 },
{ "date": "2025-10-06 09:15:00", "value": 1.0 },
{ "date": "2025-10-06 09:30:00", "value": 21.0 },
{ "date": "2025-10-06 09:45:00", "value": 14.0 },
{ "date": "2025-10-06 10:00:00", "value": 29.0 },
{ "date": "2025-10-06 10:15:00", "value": 38.0 },
{ "date": "2025-10-06 10:30:00", "value": 48.0 },
{ "date": "2025-10-06 10:45:00", "value": 44.0 },
{ "date": "2025-10-06 11:00:00", "value": 54.0 },
{ "date": "2025-10-06 11:15:00", "value": 53.0 },
{ "date": "2025-10-06 11:30:00", "value": 58.0 },
{ "date": "2025-10-06 11:45:00", "value": 35.0 },
{ "date": "2025-10-06 12:00:00", "value": 25.0 },
{ "date": "2025-10-06 12:15:00", "value": 28.0 },
{ "date": "2025-10-06 12:30:00", "value": 56.0 },
{ "date": "2025-10-06 12:45:00", "value": 56.0 },
{ "date": "2025-10-06 13:00:00", "value": 63.0 },
{ "date": "2025-10-06 13:15:00", "value": 183.0 },
{ "date": "2025-10-06 13:30:00", "value": 180.0 },
{ "date": "2025-10-06 13:45:00", "value": 104.0 },
{ "date": "2025-10-06 14:00:00", "value": 68.0 },
{ "date": "2025-10-06 14:15:00", "value": 67.0 },
{ "date": "2025-10-06 14:30:00", "value": 60.0 },
{ "date": "2025-10-06 14:45:00", "value": 57.0 },
{ "date": "2025-10-06 15:00:00", "value": 25.0 },
{ "date": "2025-10-06 15:15:00", "value": 42.0 },
{ "date": "2025-10-06 15:30:00", "value": 24.0 },
{ "date": "2025-10-06 15:45:00", "value": 11.0 },
{ "date": "2025-10-06 16:00:00", "value": 11.0 },
{ "date": "2025-10-06 16:15:00", "value": 9.0 },
{ "date": "2025-10-06 16:30:00", "value": 9.0 },
{ "date": "2025-10-06 16:45:00", "value": 3.0 },
{ "date": "2025-10-06 17:00:00", "value": 0.0 },
{ "date": "2025-10-06 17:15:00", "value": 2.0 },
{ "date": "2025-10-06 17:30:00", "value": 1.0 },
{ "date": "2025-10-06 17:45:00", "value": 1.0 },
{ "date": "2025-10-06 18:00:00", "value": 0.0 },
{ "date": "2025-10-06 18:15:00", "value": 0.0 },
{ "date": "2025-10-06 18:30:00", "value": 0.0 },
{ "date": "2025-10-06 18:45:00", "value": 0.0 },
{ "date": "2025-10-06 19:00:00", "value": null },
{ "date": "2025-10-06 19:15:00", "value": null },
{ "date": "2025-10-06 19:30:00", "value": null },
{ "date": "2025-10-06 19:45:00", "value": null },
{ "date": "2025-10-06 20:00:00", "value": null },
{ "date": "2025-10-06 20:15:00", "value": null },
{ "date": "2025-10-06 20:30:00", "value": 0.0 },
{ "date": "2025-10-06 20:45:00", "value": null },
{ "date": "2025-10-06 21:00:00", "value": null },
{ "date": "2025-10-06 21:15:00", "value": null },
{ "date": "2025-10-06 21:30:00", "value": null },
{ "date": "2025-10-06 21:45:00", "value": null },
{ "date": "2025-10-06 22:00:00", "value": null },
{ "date": "2025-10-06 22:15:00", "value": null },
{ "date": "2025-10-06 22:30:00", "value": null },
{ "date": "2025-10-06 22:45:00", "value": null },
{ "date": "2025-10-06 23:00:00", "value": null },
{ "date": "2025-10-06 23:15:00", "value": null },
{ "date": "2025-10-06 23:30:00", "value": null },
{ "date": "2025-10-06 23:45:00", "value": null }
]
}
}

93
src/main.rs Normal file
View file

@ -0,0 +1,93 @@
use axum::{
Extension, Json, Router,
response::{ErrorResponse, Html},
routing::get,
};
use chrono::prelude::*;
use dotenv::dotenv;
use serde::{Deserialize, Serialize};
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
use tower_livereload::LiveReloadLayer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok();
let app = Router::new()
.route("/api/energy", get(energy))
.route("/", get(index))
.nest_service(
"/static",
ServiceBuilder::new().service(ServeDir::new("static")),
)
.layer(LiveReloadLayer::new());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("server on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
Ok(())
}
async fn index() -> Html<String> {
let html = format!(
r#"html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0; url=/static/index.html">
</head>"#
);
Html(html)
}
async fn energy() -> axum::response::Result<Json<EnergyResponse>, ErrorResponse> {
let site_id = std::env::var("SITE_ID").unwrap();
let api_key = std::env::var("API_KEY").unwrap();
let utc_now = Utc::now().date_naive();
let url = format!(
"https://monitoringapi.solaredge.com/site/{}/energy?timeUnit=QUARTER_OF_AN_HOUR&endDate={}&startDate={}&api_key={}",
site_id, utc_now, utc_now, api_key,
);
let mut energy_response = reqwest::get(url)
.await
.map_err(|e| ErrorResponse::from(e.to_string()))?
.json::<EnergyResponse>()
.await
.map_err(|e| ErrorResponse::from(e.to_string()))?;
let values: Vec<EnergyValue> = energy_response
.energy
.values
.iter()
.map(|v| EnergyValue {
date: format!("{}+02:00", v.date.replace(' ', "T")).to_string(),
value: v.value,
})
.collect();
energy_response.energy.values = values;
Ok(Json(energy_response))
}
#[derive(Debug, Serialize, Deserialize)]
struct EnergyResponse {
energy: Energy,
}
#[derive(Debug, Serialize, Deserialize)]
struct Energy {
timeUnit: String,
unit: String,
values: Vec<EnergyValue>,
}
#[derive(Debug, Serialize, Deserialize)]
struct EnergyValue {
date: String,
value: Option<f32>,
}

11
src/overview.json Normal file
View file

@ -0,0 +1,11 @@
{
"overview": {
"lastUpdateTime": "2025-10-07 11:00:14",
"lifeTimeData": { "energy": 1.511646e7, "revenue": 0.0 },
"lastYearData": { "energy": 876057.0 },
"lastMonthData": { "energy": 38313.0 },
"lastDayData": { "energy": 544.0 },
"currentPower": { "power": 431.809 },
"measuredBy": ""
}
}

40
src/sites.json Normal file
View file

@ -0,0 +1,40 @@
{
"sites": {
"count": 1,
"site": [
{
"id": 2193535,
"name": "S. Hautvast",
"accountId": 30166,
"status": "Active",
"peakPower": 3.51,
"lastUpdateTime": "2025-10-07",
"installationDate": "2021-04-13",
"ptoDate": null,
"notes": "",
"type": "Optimizers & Inverters",
"location": {
"country": "Netherlands",
"city": "Amsterdam",
"address": "A. Moenstraat, 22",
"address2": "",
"zip": "1022 KJ",
"timeZone": "Europe/Amsterdam",
"countryCode": "NL"
},
"primaryModule": {
"manufacturerName": "Hyundai Heavy Industries",
"modelName": "HiE-S390VG",
"maximumPower": 390.0,
"temperatureCoef": -0.34
},
"uris": {
"DETAILS": "/site/2193535/details",
"DATA_PERIOD": "/site/2193535/dataPeriod",
"OVERVIEW": "/site/2193535/overview"
},
"publicSettings": { "isPublic": false }
}
]
}
}

184
static/chart.js Normal file
View file

@ -0,0 +1,184 @@
// Declare the chart dimensions and margins.
const width = 928;
const height = 500;
const marginTop = 20;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 40;
async function chart() {
const response = await fetch("/api/energy");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const energyResponse = await response.json();
const data = energyResponse.energy.values;
// Declare the x (horizontal position) scale.
const x = d3.scaleTime(
d3.extent(data, (d) => new Date(d.date)),
[marginLeft, width - marginRight],
);
// Declare the y (vertical position) scale.
const y = d3.scaleLinear(
[0, d3.max(data, (d) => d.value)],
[height - marginBottom, marginTop],
);
// Declare the line generator.
const line = d3
.line()
.x((d) => x(new Date(d.date)))
.y((d) => {
let v = d.value;
if (v == null) {
// no measurement from provider
v = 0;
}
return y(v);
});
// Create the SVG container.
const svg = d3
.select("body")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
// Add the x-axis.
svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(
d3
.axisBottom(x)
.ticks(width / 80)
.tickSizeOuter(0),
);
// Add the y-axis, remove the domain line, add grid lines and a label.
svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(d3.axisLeft(y).ticks(height / 48))
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.selectAll(".tick line")
.clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1),
)
.call((g) =>
g
.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(`energy (${energyResponse.energy.unit})`),
);
// Append a path for the line.
svg
.append("path")
.attr("fill", "lightgreen")
.attr("stroke", "steelblue")
.attr("stroke-width", 2.0)
.attr("d", line(energyResponse.energy.values));
// ============ HOVER FUNCTIONALITY ============
// Create a group for hover elements
const hoverGroup = svg
.append("g")
.attr("class", "hover-group")
.style("display", "none");
// Add vertical line (crosshair)
const verticalLine = hoverGroup
.append("line")
.attr("stroke", "#999")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4,4")
.attr("y1", marginTop)
.attr("y2", height - marginBottom);
// Add circle at intersection point
const hoverCircle = hoverGroup
.append("circle")
.attr("r", 5)
.attr("fill", "steelblue")
.attr("stroke", "white")
.attr("stroke-width", 2);
// Create tooltip
const tooltip = d3.select("body").append("div").attr("class", "tooltip");
// Bisector for finding closest data point
const bisect = d3.bisector((d) => new Date(d.date)).left;
// Create invisible overlay for capturing mouse events
svg
.append("rect")
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom)
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", function (event) {
const [mouseX] = d3.pointer(event);
// Convert mouse x position to date
const xDate = x.invert(mouseX);
// Find closest data point
const index = bisect(data, xDate, 1);
const d0 = data[index - 1];
const d1 = data[index];
// Choose closer point
const d =
d1 && xDate - new Date(d0.date) > new Date(d1.date) - xDate ? d1 : d0;
if (d) {
const xPos = x(new Date(d.date));
const yPos = y(d.value || 0);
// Show hover elements
hoverGroup.style("display", null);
// Update vertical line position
verticalLine.attr("x1", xPos).attr("x2", xPos);
// Update circle position
hoverCircle.attr("cx", xPos).attr("cy", yPos);
// Format date
const formatDate = d3.timeFormat("%H:%M");
const formatValue = d3.format(",.2f");
// Update tooltip
tooltip
.style("display", "block")
.html(
`
<strong>${formatDate(new Date(d.date))}</strong><br/>
${formatValue(d.value || 0)} ${energyResponse.energy.unit}
`,
)
.style("left", event.pageX + 15 + "px")
.style("top", event.pageY - 28 + "px");
}
})
.on("mouseout", function () {
hoverGroup.style("display", "none");
tooltip.style("display", "none");
});
}
chart();

2
static/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

10
static/index.html Normal file
View file

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<script src="/static/d3.v7.min.js"></script>
<script src="/static/chart.js"></script>
</body>
</html>

14
static/style.css Normal file
View file

@ -0,0 +1,14 @@
.tooltip {
position: absolute;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
background: rgba(100, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 1000;
}