14 Commits

Author SHA1 Message Date
c801730af9 ci: fix 2 2025-09-27 21:08:47 +02:00
93aa12c630 fix: syntax 2025-09-27 21:04:03 +02:00
046180fe63 feat: Add serilog (#5) 2025-09-27 21:00:47 +02:00
48f7065a9a fix: handle critical bug
Avoid making it get stuck in the main loop
2025-08-14 20:07:06 +02:00
32b136d4cc fix: crash on unavailable source api (#4)
* fix: crash on unavailable source api

the screen not updating because of errors

* Change temperature format if null

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 17:55:40 +02:00
Myx
8284ae9695 fix: remove redundant files 2025-07-21 03:02:17 +02:00
c575f52ebb Update README.md 2025-07-21 02:48:50 +02:00
4d2ee85cbe docs: add wiki 2025-07-21 02:23:43 +02:00
d6364fc975 docs: Update to new repo name 2025-07-21 00:07:44 +02:00
7ff39ea252 ci: remove image url 2025-07-21 00:01:53 +02:00
b61de80e8a ci: Add placeholder appsettings.json and include index page 2025-07-20 23:56:53 +02:00
c53f6a3f77 fix: readme 2025-07-20 20:01:01 +02:00
7fe92790d7 fix: add better description of what this is 2025-07-20 20:00:05 +02:00
3b9d5bc984 fix: download link 2025-07-20 19:45:21 +02:00
20 changed files with 580 additions and 236 deletions

View File

@@ -3,7 +3,7 @@ name: Build and Deploy
on: on:
push: push:
branches: branches:
- master # Change to your default branch if different - master
workflow_dispatch: workflow_dispatch:
inputs: inputs:
environment: environment:
@@ -28,7 +28,7 @@ on:
jobs: jobs:
build: build:
runs-on: self-hosted # Ensure your self-hosted runner is configured runs-on: self-hosted
environment: ${{ github.event.inputs.environment || 'prod' }} environment: ${{ github.event.inputs.environment || 'prod' }}
steps: steps:
- name: Get Current User - name: Get Current User
@@ -43,7 +43,7 @@ jobs:
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
dotnet-version: '9.0' # Change to your required .NET version dotnet-version: '9.0'
- name: Restore .NET Dependencies - name: Restore .NET Dependencies
run: dotnet restore ./HomeApi.sln run: dotnet restore ./HomeApi.sln
@@ -74,6 +74,24 @@ jobs:
} else { } else {
Write-Host "ChromeHeadlessShell not found in published output" Write-Host "ChromeHeadlessShell not found in published output"
} }
# Check wwwroot and manually copy if missing
- name: Ensure wwwroot is Included
run: |
if (-not (Test-Path -Path "./output/dotnet/wwwroot")) {
Write-Host "wwwroot folder not found in published output, manually copying..."
# Check if wwwroot exists in project directory
$sourceWwwroot = "./HomeApi/wwwroot"
if (Test-Path -Path $sourceWwwroot) {
Write-Host "Found wwwroot directory in source, copying..."
New-Item -ItemType Directory -Path "./output/dotnet/wwwroot" -Force
Copy-Item -Path "$sourceWwwroot/*" -Destination "./output/dotnet/wwwroot" -Recurse -Force
} else {
Write-Host "WARNING: Could not find wwwroot in source directory!"
}
}
- name: Generate SemVer version - name: Generate SemVer version
if: ${{ github.event.inputs.create_release != 'false' }} if: ${{ github.event.inputs.create_release != 'false' }}
@@ -81,13 +99,12 @@ jobs:
uses: ietf-tools/semver-action@v1 uses: ietf-tools/semver-action@v1
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
branch: master # ← change to "main" if that's your default branch: master
patchAll: true patchAll: true
# fallbackTag: v0.0.1 # ← optionally bootstrap from an existing tag
- name: Create GitHub Release - name: Create GitHub Release
if: ${{ github.event.inputs.create_release != 'false' }} if: ${{ github.event.inputs.create_release != 'false' }}
id: create_release # ← this is required id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -96,6 +113,54 @@ jobs:
release_name: Release ${{ steps.semver.outputs.next }} release_name: Release ${{ steps.semver.outputs.next }}
draft: false draft: false
prerelease: false prerelease: false
- name: Create placeholder appsettings for release
if: ${{ github.event.inputs.create_release != 'false' }}
run: |
$appSettings = @{
Logging = @{
LogLevel = @{
Default = "Information"
"Microsoft.AspNetCore" = "Warning"
}
}
Seq = @{
ServerUrl = "https://log.azaaxin.com"
ApiKey = "KEY"
MinimumLevel = "Trace"
LevelOverride = @{
Microsoft = "Warning"
}
}
ApiConfiguration = @{
EspConfiguration = @{
InformationBoardImageUrl = "http://server_ip:port/home/default.jpg"
UpdateIntervalMinutes = [int]"${{ vars.ESP_UPDATE_INTERVAL }}"
BlackTextThreshold = [int]"${{ vars.ESP_BLACK_TEXT_THRESHOLD }}"
EnableDithering = [System.Convert]::ToBoolean("${{ vars.ESP_ENABLE_DITHERING }}")
DitheringStrength = [int]"${{ vars.ESP_DITHERING_STRENGTH }}"
EnhanceContrast = [System.Convert]::ToBoolean("${{ vars.ESP_ENHANCE_CONTRAST }}")
ContrastStrength = [int]"${{ vars.ESP_CONTRAST_STRENGTH }}"
IsHighContrastMode = [System.Convert]::ToBoolean("${{ vars.ESP_HIGH_CONTRAST_MODE }}")
}
Keys = @{
Weather = "SET THIS TO YOUR KEY"
ResRobot = "SET THIS TO YOUR KEY"
}
BaseUrls = @{
Nominatim = "${{ vars.NOMINATIM_URL }}"
Aurora = "${{ vars.AURORA_URL }}"
Weather = "${{ vars.WEATHER_URL }}"
ResRobot = "${{ vars.RES_ROBOT_URL }}"
}
DefaultCity = "CITY ADDRESS"
DefaultStation = "YOUR STATION"
}
AllowedHosts = "*"
}
$appSettings | ConvertTo-Json -Depth 10 | Set-Content -Path "./output/dotnet/appsettings.json"
Write-Host "Created placeholder appsettings.json successfully"
- name: Zip the build for github release - name: Zip the build for github release
if: ${{ github.event.inputs.create_release != 'false' }} if: ${{ github.event.inputs.create_release != 'false' }}
@@ -107,7 +172,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} # ← now available upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/HomeScreen_Build_${{ steps.semver.outputs.next }}.zip asset_path: ./output/HomeScreen_Build_${{ steps.semver.outputs.next }}.zip
asset_name: HomeScreen_Build_${{ steps.semver.outputs.next }}.zip asset_name: HomeScreen_Build_${{ steps.semver.outputs.next }}.zip
asset_content_type: application/zip asset_content_type: application/zip
@@ -121,6 +186,14 @@ jobs:
"Microsoft.AspNetCore" = "Warning" "Microsoft.AspNetCore" = "Warning"
} }
} }
Seq = @{
ServerUrl = "${{ vars.SEQ_URL }}"
ApiKey = "${{ secrets.seq_api_key }}"
MinimumLevel = "Trace"
LevelOverride = @{
Microsoft = "Warning"
}
}
ApiConfiguration = @{ ApiConfiguration = @{
EspConfiguration = @{ EspConfiguration = @{
InformationBoardImageUrl = "${{ vars.ESP_IMAGE_URL }}" InformationBoardImageUrl = "${{ vars.ESP_IMAGE_URL }}"
@@ -151,24 +224,6 @@ jobs:
$appSettings | ConvertTo-Json -Depth 10 | Set-Content -Path "./output/dotnet/appsettings.json" $appSettings | ConvertTo-Json -Depth 10 | Set-Content -Path "./output/dotnet/appsettings.json"
Write-Host "Generated appsettings.json successfully" Write-Host "Generated appsettings.json successfully"
# Check wwwroot and manually copy if missing
- name: Ensure wwwroot is Included
run: |
if (-not (Test-Path -Path "./output/dotnet/wwwroot")) {
Write-Host "wwwroot folder not found in published output, manually copying..."
# Check if wwwroot exists in project directory
$sourceWwwroot = "./HomeApi/wwwroot"
if (Test-Path -Path $sourceWwwroot) {
Write-Host "Found wwwroot directory in source, copying..."
New-Item -ItemType Directory -Path "./output/dotnet/wwwroot" -Force
Copy-Item -Path "$sourceWwwroot/*" -Destination "./output/dotnet/wwwroot" -Recurse -Force
} else {
Write-Host "WARNING: Could not find wwwroot in source directory!"
}
}
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -176,7 +231,7 @@ jobs:
path: ./output/dotnet path: ./output/dotnet
deploy: deploy:
runs-on: self-hosted # Ensure your self-hosted runner is configured runs-on: self-hosted
needs: build needs: build
environment: ${{ github.event.inputs.environment || 'prod' }} environment: ${{ github.event.inputs.environment || 'prod' }}

View File

@@ -23,7 +23,6 @@ uint64_t sleepDuration = 30e6; // Default 30 seconds in microseconds
#define EPD_HEIGHT EPD_7IN5B_V2_HEIGHT #define EPD_HEIGHT EPD_7IN5B_V2_HEIGHT
// =========== IMAGE TUNING PARAMETERS =========== // =========== IMAGE TUNING PARAMETERS ===========
// These will be updated from the configuration
uint8_t blackTextThreshold = 190; // Default (0-255) uint8_t blackTextThreshold = 190; // Default (0-255)
bool enableDithering = true; // Default bool enableDithering = true; // Default
uint8_t ditherStrength = 8; // Default (8-32) uint8_t ditherStrength = 8; // Default (8-32)
@@ -42,13 +41,51 @@ int16_t *errorB = NULL;
// Create an instance of the JPEG decoder // Create an instance of the JPEG decoder
JPEGDEC jpeg; JPEGDEC jpeg;
// Forward declarations
bool fetchConnectionInformation();
void fetchAndDisplayImage();
void clearDisplay();
// Centralized cleanup + deep sleep
void finishAndSleep(const char* reason) {
Serial.println();
Serial.println("========== Going to deep sleep ==========");
if (reason && reason[0]) {
Serial.print("Reason: ");
Serial.println(reason);
}
// Free dithering buffers if allocated
if (errorR) { free(errorR); errorR = NULL; }
if (errorG) { free(errorG); errorG = NULL; }
if (errorB) { free(errorB); errorB = NULL; }
// Put display to sleep (safe to call even if already sleeping)
EPD_7IN5B_V2_Sleep();
// Free framebuffers
if (BlackImage) { free(BlackImage); BlackImage = NULL; }
if (RYImage) { free(RYImage); RYImage = NULL; }
// Tidy up WiFi to save power before deep sleep
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
delay(50);
Serial.print("Sleeping for ");
Serial.print(sleepDuration / 60000000);
Serial.println(" minutes");
esp_sleep_enable_timer_wakeup(sleepDuration);
esp_deep_sleep_start();
}
// Apply contrast adjustment to RGB values // Apply contrast adjustment to RGB values
void adjustContrast(uint8_t *r, uint8_t *g, uint8_t *b) { void adjustContrast(uint8_t *r, uint8_t *g, uint8_t *b) {
if (!enhanceContrast) return; if (!enhanceContrast) return;
float contrast = (contrastLevel / 100.0) + 1.0; // Convert to decimal & shift range: [0..2] float contrast = (contrastLevel / 100.0) + 1.0; // [1..2]
float intercept = 128 * (1 - contrast); float intercept = 128 * (1 - contrast);
*r = constrain((*r * contrast) + intercept, 0, 255); *r = constrain((*r * contrast) + intercept, 0, 255);
*g = constrain((*g * contrast) + intercept, 0, 255); *g = constrain((*g * contrast) + intercept, 0, 255);
*b = constrain((*b * contrast) + intercept, 0, 255); *b = constrain((*b * contrast) + intercept, 0, 255);
@@ -62,13 +99,13 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
int y = pDraw->y; int y = pDraw->y;
int width = pDraw->iWidth; int width = pDraw->iWidth;
int height = pDraw->iHeight; int height = pDraw->iHeight;
// Initialize error buffers for dithering if needed // Initialize error buffers for dithering if needed
if (enableDithering && errorR == NULL) { if (enableDithering && errorR == NULL) {
errorR = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t)); errorR = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t));
errorG = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t)); errorG = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t));
errorB = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t)); errorB = (int16_t*)malloc(EPD_WIDTH * sizeof(int16_t));
if (errorR && errorG && errorB) { if (errorR && errorG && errorB) {
memset(errorR, 0, EPD_WIDTH * sizeof(int16_t)); memset(errorR, 0, EPD_WIDTH * sizeof(int16_t));
memset(errorG, 0, EPD_WIDTH * sizeof(int16_t)); memset(errorG, 0, EPD_WIDTH * sizeof(int16_t));
@@ -81,7 +118,7 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
errorR = errorG = errorB = NULL; errorR = errorG = errorB = NULL;
} }
} }
// Process each row in this MCU block // Process each row in this MCU block
for (int iy = 0; iy < height; iy++) { for (int iy = 0; iy < height; iy++) {
// Reset error buffers for each row // Reset error buffers for each row
@@ -90,45 +127,45 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
memset(errorG, 0, EPD_WIDTH * sizeof(int16_t)); memset(errorG, 0, EPD_WIDTH * sizeof(int16_t));
memset(errorB, 0, EPD_WIDTH * sizeof(int16_t)); memset(errorB, 0, EPD_WIDTH * sizeof(int16_t));
} }
// Process each pixel in the row // Process each pixel in the row
for (int ix = 0; ix < width; ix++) { for (int ix = 0; ix < width; ix++) {
int pos_x = x + ix; int pos_x = x + ix;
int pos_y = y + iy; int pos_y = y + iy;
// Skip if outside display bounds // Skip if outside display bounds
if (pos_x >= EPD_WIDTH || pos_y >= EPD_HEIGHT) continue; if (pos_x >= EPD_WIDTH || pos_y >= EPD_HEIGHT) continue;
// Get the 16-bit pixel value (RGB565) // Get the 16-bit pixel value (RGB565)
uint16_t pixel = pPixels[iy * width + ix]; uint16_t pixel = pPixels[iy * width + ix];
// Extract RGB components (565 format) and convert to 0-255 range // Extract RGB components (565 format) and convert to 0-255 range
uint8_t r = ((pixel >> 11) & 0x1F) << 3; uint8_t r = ((pixel >> 11) & 0x1F) << 3;
uint8_t g = ((pixel >> 5) & 0x3F) << 2; uint8_t g = ((pixel >> 5) & 0x3F) << 2;
uint8_t b = (pixel & 0x1F) << 3; uint8_t b = (pixel & 0x1F) << 3;
// Apply contrast adjustment if enabled // Apply contrast adjustment if enabled
if (enhanceContrast) { if (enhanceContrast) {
adjustContrast(&r, &g, &b); adjustContrast(&r, &g, &b);
} }
// Apply dithering errors if enabled // Apply dithering errors if enabled
if (enableDithering && errorR != NULL) { if (enableDithering && errorR != NULL) {
r = constrain(r + (errorR[pos_x] / ditherStrength), 0, 255); r = constrain(r + (errorR[pos_x] / ditherStrength), 0, 255);
g = constrain(g + (errorG[pos_x] / ditherStrength), 0, 255); g = constrain(g + (errorG[pos_x] / ditherStrength), 0, 255);
b = constrain(b + (errorB[pos_x] / ditherStrength), 0, 255); b = constrain(b + (errorB[pos_x] / ditherStrength), 0, 255);
} }
// Calculate grayscale value // Calculate grayscale value
float gray = (r * 0.299 + g * 0.587 + b * 0.114); float gray = (r * 0.299 + g * 0.587 + b * 0.114);
// ===== IMPROVED COLOR CLASSIFICATION LOGIC ===== // ===== IMPROVED COLOR CLASSIFICATION LOGIC =====
// Variable for final color (0=black, 1=white, 2=red) // Variable for final color (0=black, 1=white, 2=red)
int finalColor; int finalColor;
// Check for "redness" - how much stronger red is than other components // Check for "redness" - how much stronger red is than other components
float redness = r / (float)(g + b + 1); // Add 1 to avoid division by zero float redness = r / (float)(g + b + 1); // Add 1 to avoid division by zero
// Check if this is likely a red pixel based on redness // Check if this is likely a red pixel based on redness
if (r > 100 && redness > 1.5) { if (r > 100 && redness > 1.5) {
finalColor = 2; // Red finalColor = 2; // Red
@@ -140,10 +177,10 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
else { else {
finalColor = 1; // White finalColor = 1; // White
} }
// Determine target colors for error calculation // Determine target colors for error calculation
uint8_t targetR, targetG, targetB; uint8_t targetR, targetG, targetB;
switch (finalColor) { switch (finalColor) {
case 0: // Black case 0: // Black
targetR = targetG = targetB = 0; targetR = targetG = targetB = 0;
@@ -156,13 +193,13 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
targetR = targetG = targetB = 255; targetR = targetG = targetB = 255;
break; break;
} }
// Calculate and distribute dithering errors // Calculate and distribute dithering errors
if (enableDithering && errorR != NULL) { if (enableDithering && errorR != NULL) {
int16_t err_r = r - targetR; int16_t err_r = r - targetR;
int16_t err_g = g - targetG; int16_t err_g = g - targetG;
int16_t err_b = b - targetB; int16_t err_b = b - targetB;
// Floyd-Steinberg dithering pattern // Floyd-Steinberg dithering pattern
if (pos_x + 1 < EPD_WIDTH) { if (pos_x + 1 < EPD_WIDTH) {
// Right pixel (7/16) // Right pixel (7/16)
@@ -170,22 +207,22 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
errorG[pos_x + 1] += (err_g * 7) >> 4; errorG[pos_x + 1] += (err_g * 7) >> 4;
errorB[pos_x + 1] += (err_b * 7) >> 4; errorB[pos_x + 1] += (err_b * 7) >> 4;
} }
if (pos_x > 0 && pos_x + 1 < EPD_WIDTH) { if (pos_x > 0 && pos_x + 1 < EPD_WIDTH) {
errorR[pos_x - 1] += (err_r * 3) >> 4; // left-down (3/16) errorR[pos_x - 1] += (err_r * 3) >> 4; // left-down (3/16)
errorG[pos_x - 1] += (err_g * 3) >> 4; errorG[pos_x - 1] += (err_g * 3) >> 4;
errorB[pos_x - 1] += (err_b * 3) >> 4; errorB[pos_x - 1] += (err_b * 3) >> 4;
errorR[pos_x] += (err_r * 5) >> 4; // down (5/16) errorR[pos_x] += (err_r * 5) >> 4; // down (5/16)
errorG[pos_x] += (err_g * 5) >> 4; errorG[pos_x] += (err_g * 5) >> 4;
errorB[pos_x] += (err_b * 5) >> 4; errorB[pos_x] += (err_b * 5) >> 4;
errorR[pos_x + 1] += (err_r * 1) >> 4; // right-down (1/16) errorR[pos_x + 1] += (err_r * 1) >> 4; // right-down (1/16)
errorG[pos_x + 1] += (err_g * 1) >> 4; errorG[pos_x + 1] += (err_g * 1) >> 4;
errorB[pos_x + 1] += (err_b * 1) >> 4; errorB[pos_x + 1] += (err_b * 1) >> 4;
} }
} }
// Draw the pixel based on the final color // Draw the pixel based on the final color
switch (finalColor) { switch (finalColor) {
case 0: // Black case 0: // Black
@@ -194,14 +231,14 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
Paint_SelectImage(RYImage); Paint_SelectImage(RYImage);
Paint_SetPixel(pos_x, pos_y, WHITE); Paint_SetPixel(pos_x, pos_y, WHITE);
break; break;
case 2: // Red case 2: // Red
Paint_SelectImage(BlackImage); Paint_SelectImage(BlackImage);
Paint_SetPixel(pos_x, pos_y, WHITE); Paint_SetPixel(pos_x, pos_y, WHITE);
Paint_SelectImage(RYImage); Paint_SelectImage(RYImage);
Paint_SetPixel(pos_x, pos_y, BLACK); // BLACK in RY buffer = RED Paint_SetPixel(pos_x, pos_y, BLACK); // BLACK in RY buffer = RED
break; break;
default: // White default: // White
Paint_SelectImage(BlackImage); Paint_SelectImage(BlackImage);
Paint_SetPixel(pos_x, pos_y, WHITE); Paint_SetPixel(pos_x, pos_y, WHITE);
@@ -211,26 +248,39 @@ int jpegDrawCallback(JPEGDRAW *pDraw) {
} }
} }
} }
return 1; // Continue decoding return 1; // Continue decoding
} }
void clearDisplay() {
// Clear both layers of the display
Paint_SelectImage(BlackImage);
Paint_Clear(WHITE);
Paint_SelectImage(RYImage);
Paint_Clear(WHITE);
// Send clear command to the display
EPD_7IN5B_V2_Display(BlackImage, RYImage);
Serial.println("Display cleared.");
}
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
Serial.println("E-Ink Display Initialization"); Serial.println("E-Ink Display Initialization");
// Calculate buffer size as in Waveshare example // Calculate buffer size as in Waveshare example
UWORD Imagesize = ((EPD_WIDTH % 8 == 0) ? (EPD_WIDTH / 8) : (EPD_WIDTH / 8 + 1)) * EPD_HEIGHT; UWORD Imagesize = ((EPD_WIDTH % 8 == 0) ? (EPD_WIDTH / 8) : (EPD_WIDTH / 8 + 1)) * EPD_HEIGHT;
// Allocate framebuffers // Allocate framebuffers
BlackImage = (UBYTE *)malloc(Imagesize); BlackImage = (UBYTE *)malloc(Imagesize);
RYImage = (UBYTE *)malloc(Imagesize); RYImage = (UBYTE *)malloc(Imagesize);
if ((BlackImage == NULL) || (RYImage == NULL)) { if ((BlackImage == NULL) || (RYImage == NULL)) {
Serial.println("Failed to allocate memory for framebuffers!"); Serial.println("Failed to allocate memory for framebuffers!");
while(1); // Halt if memory allocation fails // Even on failure, deep-sleep to allow recovery on next boot
finishAndSleep("Framebuffer allocation failed");
} }
// Initialize e-ink display exactly as in Waveshare example // Initialize e-ink display exactly as in Waveshare example
DEV_Module_Init(); DEV_Module_Init();
EPD_7IN5B_V2_Init(); EPD_7IN5B_V2_Init();
@@ -240,10 +290,11 @@ void setup() {
// Initialize the Paint library with the buffers // Initialize the Paint library with the buffers
Paint_NewImage(BlackImage, EPD_WIDTH, EPD_HEIGHT, 0, WHITE); Paint_NewImage(BlackImage, EPD_WIDTH, EPD_HEIGHT, 0, WHITE);
Paint_NewImage(RYImage, EPD_WIDTH, EPD_HEIGHT, 0, WHITE); Paint_NewImage(RYImage, EPD_WIDTH, EPD_HEIGHT, 0, WHITE);
Serial.println("Buffers allocated and cleared"); Serial.println("Buffers allocated and cleared");
// Connect to WiFi // Connect to WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password); WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi"); Serial.print("Connecting to WiFi");
int wifiAttempts = 0; int wifiAttempts = 0;
@@ -252,7 +303,7 @@ void setup() {
Serial.print("."); Serial.print(".");
wifiAttempts++; wifiAttempts++;
} }
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
Serial.println(); Serial.println();
Serial.println("WiFi connected"); Serial.println("WiFi connected");
@@ -261,7 +312,8 @@ void setup() {
} else { } else {
Serial.println(); Serial.println();
Serial.println("WiFi connection failed!"); Serial.println("WiFi connection failed!");
return; // IMPORTANT: Do not just return; always deep sleep so we retry later
finishAndSleep("WiFi connection failed");
} }
// Test connectivity to server and get configuration // Test connectivity to server and get configuration
@@ -273,67 +325,48 @@ void setup() {
Serial.println("Server connectivity test failed - skipping image fetch"); Serial.println("Server connectivity test failed - skipping image fetch");
} }
// Free dithering buffers if allocated // Always finish with cleanup and deep sleep (success or failure)
if (errorR) free(errorR); finishAndSleep("Normal cycle complete");
if (errorG) free(errorG);
if (errorB) free(errorB);
errorR = errorG = errorB = NULL;
// Put display to sleep
EPD_7IN5B_V2_Sleep();
// Free framebuffers
free(BlackImage);
free(RYImage);
BlackImage = NULL;
RYImage = NULL;
// Enter deep sleep
Serial.print("Going to sleep for ");
Serial.print(sleepDuration / 60000000);
Serial.println(" minutes");
esp_sleep_enable_timer_wakeup(sleepDuration);
esp_deep_sleep_start();
} }
bool fetchConnectionInformation() { bool fetchConnectionInformation() {
HTTPClient http; HTTPClient http;
http.begin(connectionInformation); http.begin(connectionInformation);
http.setTimeout(10000); http.setTimeout(10000);
int httpCode = http.GET(); int httpCode = http.GET();
Serial.print("HTTP response code: "); Serial.print("HTTP response code: ");
Serial.println(httpCode); Serial.println(httpCode);
// Handle the response payload // Handle the response payload
String payload = ""; String payload = "";
if (httpCode == HTTP_CODE_OK) { if (httpCode == HTTP_CODE_OK) {
payload = http.getString(); payload = http.getString();
// Debug output to show the exact response // Debug output to show the exact response
Serial.println("-----RAW HTTP RESPONSE BEGIN-----"); Serial.println("-----RAW HTTP RESPONSE BEGIN-----");
Serial.println(payload); Serial.println(payload);
Serial.println("-----RAW HTTP RESPONSE END-----"); Serial.println("-----RAW HTTP RESPONSE END-----");
// Check if payload is empty // Check if payload is empty
if (payload.length() == 0) { if (payload.length() == 0) {
Serial.println("Warning: Server returned empty response"); Serial.println("Warning: Server returned empty response");
http.end(); http.end();
return false; return false;
} }
// Try to find JSON content in the response // Try to find JSON content in the response
int jsonStart = payload.indexOf('{'); int jsonStart = payload.indexOf('{');
int jsonEnd = payload.lastIndexOf('}'); int jsonEnd = payload.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd >= 0 && jsonEnd > jsonStart) { if (jsonStart >= 0 && jsonEnd >= 0 && jsonEnd > jsonStart) {
String jsonPayload = payload.substring(jsonStart, jsonEnd + 1); String jsonPayload = payload.substring(jsonStart, jsonEnd + 1);
Serial.println("-----EXTRACTED JSON BEGIN-----"); Serial.println("-----EXTRACTED JSON BEGIN-----");
Serial.println(jsonPayload); Serial.println(jsonPayload);
Serial.println("-----EXTRACTED JSON END-----"); Serial.println("-----EXTRACTED JSON END-----");
// Deserialize the JSON document - Increased buffer size for more parameters // Deserialize the JSON document - Increased buffer size for more parameters
StaticJsonDocument<768> doc; StaticJsonDocument<768> doc;
DeserializationError error = deserializeJson(doc, jsonPayload); DeserializationError error = deserializeJson(doc, jsonPayload);
@@ -343,7 +376,7 @@ bool fetchConnectionInformation() {
http.end(); http.end();
return false; return false;
} }
// Extract values from the JSON // Extract values from the JSON
if (doc.containsKey("informationBoardImageUrl")) { if (doc.containsKey("informationBoardImageUrl")) {
imageUrl = doc["informationBoardImageUrl"].as<String>(); imageUrl = doc["informationBoardImageUrl"].as<String>();
@@ -354,7 +387,7 @@ bool fetchConnectionInformation() {
http.end(); http.end();
return false; return false;
} }
if (doc.containsKey("updateIntervalMinutes")) { if (doc.containsKey("updateIntervalMinutes")) {
int minutes = doc["updateIntervalMinutes"].as<int>(); int minutes = doc["updateIntervalMinutes"].as<int>();
sleepDuration = (uint64_t)minutes * 60 * 1000000; // Convert minutes to microseconds sleepDuration = (uint64_t)minutes * 60 * 1000000; // Convert minutes to microseconds
@@ -365,38 +398,38 @@ bool fetchConnectionInformation() {
Serial.println("Warning: updateIntervalMinutes not found in JSON"); Serial.println("Warning: updateIntervalMinutes not found in JSON");
// Keep default sleep duration // Keep default sleep duration
} }
// Extract new image processing parameters // Extract new image processing parameters
if (doc.containsKey("blackTextThreshold")) { if (doc.containsKey("blackTextThreshold")) {
blackTextThreshold = doc["blackTextThreshold"].as<uint8_t>(); blackTextThreshold = doc["blackTextThreshold"].as<uint8_t>();
Serial.print("Black text threshold set to: "); Serial.print("Black text threshold set to: ");
Serial.println(blackTextThreshold); Serial.println(blackTextThreshold);
} }
if (doc.containsKey("enableDithering")) { if (doc.containsKey("enableDithering")) {
enableDithering = doc["enableDithering"].as<bool>(); enableDithering = doc["enableDithering"].as<bool>();
Serial.print("Dithering enabled: "); Serial.print("Dithering enabled: ");
Serial.println(enableDithering ? "true" : "false"); Serial.println(enableDithering ? "true" : "false");
} }
if (doc.containsKey("ditheringStrength")) { if (doc.containsKey("ditheringStrength")) {
ditherStrength = doc["ditheringStrength"].as<uint8_t>(); ditherStrength = doc["ditheringStrength"].as<uint8_t>();
Serial.print("Dithering strength set to: "); Serial.print("Dithering strength set to: ");
Serial.println(ditherStrength); Serial.println(ditherStrength);
} }
if (doc.containsKey("enhanceContrast")) { if (doc.containsKey("enhanceContrast")) {
enhanceContrast = doc["enhanceContrast"].as<bool>(); enhanceContrast = doc["enhanceContrast"].as<bool>();
Serial.print("Contrast enhancement enabled: "); Serial.print("Contrast enhancement enabled: ");
Serial.println(enhanceContrast ? "true" : "false"); Serial.println(enhanceContrast ? "true" : "false");
} }
if (doc.containsKey("contrastStrength")) { if (doc.containsKey("contrastStrength")) {
contrastLevel = doc["contrastStrength"].as<uint8_t>(); contrastLevel = doc["contrastStrength"].as<uint8_t>();
Serial.print("Contrast level set to: "); Serial.print("Contrast level set to: ");
Serial.println(contrastLevel); Serial.println(contrastLevel);
} }
http.end(); http.end();
return true; return true;
} else { } else {
@@ -418,51 +451,51 @@ void fetchAndDisplayImage() {
Serial.println("WiFi not connected, cannot fetch image"); Serial.println("WiFi not connected, cannot fetch image");
return; return;
} }
if (imageUrl.length() == 0) { if (imageUrl.length() == 0) {
Serial.println("Image URL not set, cannot fetch image"); Serial.println("Image URL not set, cannot fetch image");
return; return;
} }
HTTPClient http; HTTPClient http;
http.begin(imageUrl); http.begin(imageUrl);
http.setTimeout(30000); // Set 30 second timeout http.setTimeout(30000); // Set 30 second timeout
http.addHeader("User-Agent", "ESP32"); http.addHeader("User-Agent", "ESP32");
Serial.print("Starting HTTP GET for image: "); Serial.print("Starting HTTP GET for image: ");
Serial.println(imageUrl); Serial.println(imageUrl);
int httpCode = http.GET(); int httpCode = http.GET();
Serial.print("HTTP response code: "); Serial.print("HTTP response code: ");
Serial.println(httpCode); Serial.println(httpCode);
if (httpCode == HTTP_CODE_OK) { if (httpCode == HTTP_CODE_OK) {
int len = http.getSize(); int len = http.getSize();
Serial.print("Content length: "); Serial.print("Content length: ");
Serial.println(len); Serial.println(len);
if (len > 0) { if (len > 0) {
Serial.print("Free heap before allocation: "); Serial.print("Free heap before allocation: ");
Serial.println(ESP.getFreeHeap()); Serial.println(ESP.getFreeHeap());
// Check if we have enough memory // Check if we have enough memory
if (ESP.getFreeHeap() < len + 10000) { // Keep 10KB buffer if (ESP.getFreeHeap() < len + 10000) { // Keep 10KB buffer
Serial.println("Not enough memory to load image"); Serial.println("Not enough memory to load image");
http.end(); http.end();
return; return;
} }
uint8_t *buffer = (uint8_t*)malloc(len); uint8_t *buffer = (uint8_t*)malloc(len);
if (buffer) { if (buffer) {
Serial.print("Allocated "); Serial.print("Allocated ");
Serial.print(len); Serial.print(len);
Serial.println(" bytes for image buffer."); Serial.println(" bytes for image buffer.");
// Clear both buffers before processing new image - USING PAINT LIBRARY // Clear both buffers before processing new image - USING PAINT LIBRARY
Paint_SelectImage(BlackImage); Paint_SelectImage(BlackImage);
Paint_Clear(WHITE); Paint_Clear(WHITE);
Paint_SelectImage(RYImage); Paint_SelectImage(RYImage);
Paint_Clear(WHITE); Paint_Clear(WHITE);
WiFiClient *stream = http.getStreamPtr(); WiFiClient *stream = http.getStreamPtr();
int totalBytesRead = 0; int totalBytesRead = 0;
unsigned long timeout = millis() + 30000; // 30 second timeout for reading unsigned long timeout = millis() + 30000; // 30 second timeout for reading
@@ -479,14 +512,15 @@ void fetchAndDisplayImage() {
} else { } else {
totalBytesRead += bytesRead; totalBytesRead += bytesRead;
} }
yield(); // keep WiFi stack happy
} }
Serial.print("Total bytes read: "); Serial.print("Total bytes read: ");
Serial.println(totalBytesRead); Serial.println(totalBytesRead);
if (totalBytesRead == len) { if (totalBytesRead == len) {
// Process and display the image using JPEGDEC // Process and display the image using JPEGDEC
Serial.println("Decoding JPEG image..."); Serial.println("Decoding JPEG image...");
// Open JPEG image from memory // Open JPEG image from memory
if (jpeg.openRAM(buffer, len, jpegDrawCallback)) { if (jpeg.openRAM(buffer, len, jpegDrawCallback)) {
// Get information about the image // Get information about the image
@@ -496,46 +530,55 @@ void fetchAndDisplayImage() {
Serial.print(jpegWidth); Serial.print(jpegWidth);
Serial.print(" x "); Serial.print(" x ");
Serial.println(jpegHeight); Serial.println(jpegHeight);
// Decode the image // Decode the image
if (jpeg.decode(0, 0, 0)) { if (jpeg.decode(0, 0, 0)) {
Serial.println("JPEG image decoded successfully"); Serial.println("JPEG image decoded successfully");
} else { } else {
Serial.println("Error decoding JPEG image"); Serial.println("Error decoding JPEG image");
} }
// Close the file // Close the file
jpeg.close(); jpeg.close();
// Display the processed image - using Waveshare's function // Display the processed image - using Waveshare's function
Serial.println("Sending image to display..."); Serial.println("Sending image to display...");
EPD_7IN5B_V2_Display(BlackImage, RYImage); EPD_7IN5B_V2_Display(BlackImage, RYImage);
Serial.println("Image displayed successfully."); Serial.println("Image displayed successfully.");
} else { } else {
Serial.println("Failed to open JPEG image"); Serial.println("Failed to open JPEG image");
clearDisplay();
} }
} else { } else {
Serial.println("Failed to read entire image."); Serial.println("Failed to read entire image.");
clearDisplay();
} }
free(buffer); free(buffer);
} else { } else {
Serial.println("Failed to allocate buffer!"); Serial.println("Failed to allocate buffer!");
clearDisplay();
} }
} else { } else {
Serial.println("Content length unknown or invalid."); Serial.println("Content length unknown or invalid.");
clearDisplay();
} }
} else if (httpCode == HTTPC_ERROR_CONNECTION_REFUSED) { } else if (httpCode == HTTPC_ERROR_CONNECTION_REFUSED) {
Serial.println("Connection refused - server may be down"); Serial.println("Connection refused - server may be down");
clearDisplay();
} else if (httpCode == HTTPC_ERROR_CONNECTION_LOST) { } else if (httpCode == HTTPC_ERROR_CONNECTION_LOST) {
Serial.println("Connection lost during request"); Serial.println("Connection lost during request");
clearDisplay();
} else if (httpCode == HTTPC_ERROR_NO_HTTP_SERVER) { } else if (httpCode == HTTPC_ERROR_NO_HTTP_SERVER) {
Serial.println("No HTTP server found"); Serial.println("No HTTP server found");
clearDisplay();
} else if (httpCode == HTTPC_ERROR_NOT_CONNECTED) { } else if (httpCode == HTTPC_ERROR_NOT_CONNECTED) {
Serial.println("Not connected to server"); Serial.println("Not connected to server");
clearDisplay();
} else { } else {
Serial.printf("HTTP GET failed, error: %d\n", httpCode); Serial.printf("HTTP GET failed, error: %d\n", httpCode);
clearDisplay();
} }
http.end(); http.end();
} }

View File

@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
</Project>

View File

@@ -18,10 +18,10 @@ public static class ContractExtensions
/// A <see cref="WeatherInformation"/> object populated with current weather, aurora probability, and forecast information. /// A <see cref="WeatherInformation"/> object populated with current weather, aurora probability, and forecast information.
/// </returns> /// </returns>
public static WeatherInformation ToContract( public static WeatherInformation ToContract(
this WeatherData weather, this WeatherData? weather,
string locationName, string? locationName,
AuroraForecastApiResponse? auroraForecast, AuroraForecastApiResponse? auroraForecast,
List<Models.Forecast> forecasts) List<Models.Forecast>? forecasts)
{ {
return new WeatherInformation return new WeatherInformation
{ {
@@ -58,23 +58,23 @@ public static class ContractExtensions
}, },
AuroraProbability = new Probability AuroraProbability = new Probability
{ {
Date = auroraForecast.Date, Date = auroraForecast?.Date ?? DateTime.Now,
Calculated = new CalculatedProbability Calculated = new CalculatedProbability
{ {
Value = auroraForecast.Probability.Calculated.Value, Value = auroraForecast?.Probability.Calculated.Value ?? 0,
Colour = auroraForecast.Probability.Calculated.Colour, Colour = auroraForecast?.Probability.Calculated.Colour ?? string.Empty,
Lat = auroraForecast.Probability.Calculated.Lat, Lat = auroraForecast?.Probability.Calculated.Lat ?? 0,
Long = auroraForecast.Probability.Calculated.Long Long = auroraForecast?.Probability.Calculated.Long ?? 0
}, },
Colour = auroraForecast.Probability.Colour, Colour = auroraForecast?.Probability.Colour ?? string.Empty,
Value = auroraForecast.Probability.Value, Value = auroraForecast?.Probability.Value.ToString() ?? "No data",
HighestProbability = new Highest HighestProbability = new Highest
{ {
Colour = auroraForecast.Probability.Highest.Colour, Colour = auroraForecast?.Probability.Highest.Colour ?? string.Empty,
Lat = auroraForecast.Probability.Highest.Lat, Lat = auroraForecast?.Probability.Highest.Lat ?? 0,
Long = auroraForecast.Probability.Highest.Long, Long = auroraForecast?.Probability.Highest.Long ?? 0,
Value = auroraForecast.Probability.Highest.Value, Value = auroraForecast?.Probability.Highest.Value ?? 0,
Date = auroraForecast.Probability.Highest.Date Date = auroraForecast?.Probability.Highest.Date ?? DateTime.Now
} }
} }
}, },

View File

@@ -0,0 +1,22 @@
using MediatR;
namespace HomeApi.Extensions;
public static class MediatorExtensions
{
public static async Task<T?> TrySendAsync<T>(
this IMediator mediator,
IRequest<T> request,
CancellationToken cancellationToken) where T : class
{
try
{
return await mediator.Send(request, cancellationToken);
}
catch (OperationCanceledException) { throw; }
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace HomeApi.Extensions;
public static class ServiceCallExtensions
{
public static async Task<TResult?> TryCallAsync<TService, TResult>(
this TService service,
Func<TService, Task<TResult>> action,
ILogger logger,
string errorMessage)
where TResult : class?
{
try
{
return await action(service);
}
catch (OperationCanceledException) { throw; }
catch (Exception exception)
{
logger.LogError(exception, errorMessage);
return null;
}
}
}

View File

@@ -1,5 +1,6 @@
using System.Dynamic; using System.Dynamic;
using System.Reflection; using System.Reflection;
using HomeApi.Extensions;
using HomeApi.Models.Configuration; using HomeApi.Models.Configuration;
using MediatR; using MediatR;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -22,8 +23,8 @@ public static class ImageGeneration
public async Task<Stream> Handle(Command request, CancellationToken cancellationToken) public async Task<Stream> Handle(Command request, CancellationToken cancellationToken)
{ {
var weather = await mediator.Send(new Weather.Command(), cancellationToken); var weather = await mediator.TrySendAsync(new Weather.Command(), cancellationToken);
var departureBoard = await mediator.Send(new DepartureBoard.Command(), cancellationToken); var departureBoard = await mediator.TrySendAsync(new DepartureBoard.Command(), cancellationToken);
var model = new Models.Image var model = new Models.Image
{ {
@@ -31,8 +32,6 @@ public static class ImageGeneration
TimeTable = departureBoard TimeTable = departureBoard
}; };
if(weather is null)
throw new Exception("Weather data not found");
var engine = new RazorLightEngineBuilder() var engine = new RazorLightEngineBuilder()
.SetOperatingAssembly(Assembly.GetExecutingAssembly()) .SetOperatingAssembly(Assembly.GetExecutingAssembly())
@@ -59,7 +58,7 @@ public static class ImageGeneration
{ {
var browserFetcher = new BrowserFetcher(); var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync(); await browserFetcher.DownloadAsync();
var browser = await Puppeteer.LaunchAsync(new LaunchOptions await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{ {
Headless = true, Headless = true,
Args = ["--disable-gpu"] Args = ["--disable-gpu"]

View File

@@ -35,16 +35,27 @@ public static class Weather
public async Task<WeatherInformation> Handle(Command request, CancellationToken cancellationToken) public async Task<WeatherInformation> Handle(Command request, CancellationToken cancellationToken)
{ {
var coordinates = await _geocodingService.GetCoordinatesAsync(_apiConfiguration.DefaultCity); var coordinates = await _geocodingService.TryCallAsync(service =>
if (coordinates is null) service.GetCoordinatesAsync(_apiConfiguration.DefaultCity),
throw new Exception("Coordinates not found"); _logger,
"Failed to get coordinates"
);
var aurora = await _auroraService.TryCallAsync(service =>
service.GetAuroraForecastAsync(coordinates?.Lat ?? "0.00", coordinates?.Lon ?? "0.00"),
_logger,
"Failed to get aurora forecast"
);
var weather = await _weatherService.TryCallAsync(service =>
service.GetWeatherAsync(coordinates?.Lat ?? string.Empty, coordinates?.Lon ?? string.Empty),
_logger,
"Failed to get weather data"
);
var aurora = await _auroraService.GetAuroraForecastAsync(coordinates.Lat, coordinates.Lon); var forecasts = weather?.Forecast.Forecastday.Select(day => day.ToForecast()).ToList();
var weather = await _weatherService.GetWeatherAsync(coordinates.Lat, coordinates.Lon);
var forecasts = weather.Forecast.Forecastday.Select(day => day.ToForecast()).ToList(); return weather?.ToContract(coordinates?.Name, aurora, forecasts) ?? new WeatherInformation();
return weather.ToContract(coordinates.Name, aurora, forecasts);
} }
} }
} }

View File

@@ -17,6 +17,7 @@
<PackageReference Include="RazorLight" Version="2.3.1" /> <PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" /> <PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.5.6" /> <PackageReference Include="Scalar.AspNetCore" Version="2.5.6" />
<PackageReference Include="Seq.Extensions.Logging" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -30,5 +31,8 @@
<Content Include="wwwroot\index.cshtml"> <Content Include="wwwroot\index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="..\.github\workflows\build.yml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -14,9 +14,15 @@ public class WeatherService(IWeatherClient weatherApi, IOptions<ApiConfiguration
{ {
private readonly ApiConfiguration _apiConfig = options.Value; private readonly ApiConfiguration _apiConfig = options.Value;
public Task<WeatherData> GetWeatherAsync(string lat, string lon) public Task<WeatherData> GetWeatherAsync(string? lat, string? lon)
{ {
var location = $"{lat},{lon}"; var location = $"{lat},{lon}";
if (string.IsNullOrEmpty(lat) || string.IsNullOrEmpty(lon))
{
location = _apiConfig.DefaultCity;
}
return weatherApi.GetForecastAsync(_apiConfig.Keys.Weather, location); return weatherApi.GetForecastAsync(_apiConfig.Keys.Weather, location);
} }
} }

View File

@@ -2,6 +2,6 @@ namespace HomeApi.Models;
public class Image public class Image
{ {
public WeatherInformation Weather { get; set; } public WeatherInformation? Weather { get; set; }
public List<TimeTable> TimeTable { get; set; } public List<TimeTable>? TimeTable { get; set; }
} }

View File

@@ -1,4 +1,5 @@
namespace HomeApi.Models.Response; namespace HomeApi.Models.Response;
public class TrafikLabsApiResponse public class TrafikLabsApiResponse
{ {
public List<Departure> Departure { get; set; } public List<Departure> Departure { get; set; }

View File

@@ -0,0 +1,153 @@
using System.Text.Json.Serialization;
namespace HomeApi.Models.Response;
public class Data
{
[JsonPropertyName("instant")]
public Instant Instant { get; set; }
[JsonPropertyName("next_12_hours")]
public Next12Hours Next12Hours { get; set; }
[JsonPropertyName("next_1_hours")]
public Next1Hours Next1Hours { get; set; }
[JsonPropertyName("next_6_hours")]
public Next6Hours Next6Hours { get; set; }
}
public class Details
{
[JsonPropertyName("air_pressure_at_sea_level")]
public double AirPressureAtSeaLevel { get; set; }
[JsonPropertyName("air_temperature")]
public double AirTemperature { get; set; }
[JsonPropertyName("cloud_area_fraction")]
public double CloudAreaFraction { get; set; }
[JsonPropertyName("relative_humidity")]
public double RelativeHumidity { get; set; }
[JsonPropertyName("wind_from_direction")]
public double WindFromDirection { get; set; }
[JsonPropertyName("wind_speed")]
public double WindSpeed { get; set; }
[JsonPropertyName("precipitation_amount")]
public double PrecipitationAmount { get; set; }
}
public class Geometry
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("coordinates")]
public List<double> Coordinates { get; set; }
}
public class Instant
{
[JsonPropertyName("details")]
public Details Details { get; set; }
}
public class Meta
{
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("units")]
public Units Units { get; set; }
}
public class Next12Hours
{
[JsonPropertyName("summary")]
public Summary Summary { get; set; }
[JsonPropertyName("details")]
public Details Details { get; set; }
}
public class Next1Hours
{
[JsonPropertyName("summary")]
public Summary Summary { get; set; }
[JsonPropertyName("details")]
public Details Details { get; set; }
}
public class Next6Hours
{
[JsonPropertyName("summary")]
public Summary Summary { get; set; }
[JsonPropertyName("details")]
public Details Details { get; set; }
}
public class Properties
{
[JsonPropertyName("meta")]
public Meta Meta { get; set; }
[JsonPropertyName("timeseries")]
public List<Timeseries> Timeseries { get; set; }
}
public class YrWeatherForecastResponse
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("geometry")]
public Geometry Geometry { get; set; }
[JsonPropertyName("properties")]
public Properties Properties { get; set; }
}
public class Summary
{
[JsonPropertyName("symbol_code")]
public string SymbolCode { get; set; }
}
public class Timeseries
{
[JsonPropertyName("time")]
public DateTime Time { get; set; }
[JsonPropertyName("data")]
public Data Data { get; set; }
}
public class Units
{
[JsonPropertyName("air_pressure_at_sea_level")]
public string AirPressureAtSeaLevel { get; set; }
[JsonPropertyName("air_temperature")]
public string AirTemperature { get; set; }
[JsonPropertyName("cloud_area_fraction")]
public string CloudAreaFraction { get; set; }
[JsonPropertyName("precipitation_amount")]
public string PrecipitationAmount { get; set; }
[JsonPropertyName("relative_humidity")]
public string RelativeHumidity { get; set; }
[JsonPropertyName("wind_from_direction")]
public string WindFromDirection { get; set; }
[JsonPropertyName("wind_speed")]
public string WindSpeed { get; set; }
}

View File

@@ -1,24 +1,26 @@
#nullable disable
using HomeApi.Models.Response; using HomeApi.Models.Response;
namespace HomeApi.Models; namespace HomeApi.Models;
public class WeatherInformation public class WeatherInformation
{ {
public string CityName { get; set; } = string.Empty; public string CityName { get; set; } = "Not defined";
public Current Current { get; set; } = new(); public Current Current { get; set; } = new();
public List<Forecast> Forecast { get; set; } public List<Forecast> Forecast { get; set; }
} }
public class Current public class Current
{ {
public string Date { get; set; } public string Date { get; set; } = string.Empty;
public double Feelslike { get; set; } public double Feelslike { get; set; } = 0;
public int IsDay { get; set; } public int IsDay { get; set; } = 1;
public double WindPerMeterSecond { get; set; } = 0; public double WindPerMeterSecond { get; set; } = 0;
public double WindGustPerMeterSecond { get; set; } = 0; public double WindGustPerMeterSecond { get; set; } = 0;
public double Temperature { get; set; } = 0; public double Temperature { get; set; } = 0;
public string LastUpdated { get; set; } = string.Empty; public string LastUpdated { get; set; } = string.Empty;
public int Cloud { get; set; } public int Cloud { get; set; } = 0;
public string WindDirection { get; set; } = string.Empty; public string WindDirection { get; set; } = string.Empty;
public Location WeatherDataLocation { get; set; } = new(); public Location WeatherDataLocation { get; set; } = new();
public AirQuality AirQuality { get; set; } public AirQuality AirQuality { get; set; }
@@ -28,72 +30,72 @@ public class Current
public class Location public class Location
{ {
public string Name { get; set; } public string Name { get; set; } = string.Empty;
public string Region { get; set; } public string Region { get; set; } = string.Empty;
public string Country { get; set; } public string Country { get; set; } = string.Empty;
public double Lat { get; set; } public double Lat { get; set; } = 0;
public double Lon { get; set; } public double Lon { get; set; } = 0;
} }
public class Probability public class Probability
{ {
public DateTime Date { get; set; } public DateTime Date { get; set; } = DateTime.Now;
public CalculatedProbability Calculated { get; set; } // my location? public CalculatedProbability Calculated { get; set; } = new();
public string Colour { get; set; } public string Colour { get; set; } = string.Empty;
public int Value { get; set; } public string Value { get; set; } = string.Empty;
public Highest HighestProbability { get; set; } public Highest HighestProbability { get; set; } = new();
} }
public class Highest public class Highest
{ {
public DateTime Date { get; set; } public DateTime Date { get; set; } = DateTime.Now;
public string Colour { get; set; } public string Colour { get; set; } = string.Empty;
public double Lat { get; set; } public double Lat { get; set; } = 0;
public double Long { get; set; } public double Long { get; set; } = 0;
public int Value { get; set; } public int Value { get; set; } = 0;
} }
public class Forecast public class Forecast
{ {
public string Date { get; set; } public string Date { get; set; } = string.Empty;
public double MinTempC { get; set; } public double MinTempC { get; set; } = 0;
public double MaxTempC { get; set; } public double MaxTempC { get; set; } = 0;
public string DayIcon { get; set; } public string DayIcon { get; set; } = string.Empty;
public WeatherSummary? Day { get; set; } public WeatherSummary? Day { get; set; } = null;
public WeatherSummary? Night { get; set; } public WeatherSummary? Night { get; set; } = null;
public Astro Astro { get; set; } public Astro Astro { get; set; } = new();
public int IconCode { get; set; } public int IconCode { get; set; } = 0;
public int ChanceOfRain { get; set; } public int ChanceOfRain { get; set; } = 0;
} }
public class Astro public class Astro
{ {
public string Sunrise { get; set; } public string Sunrise { get; set; } = string.Empty;
public string Sunset { get; set; } public string Sunset { get; set; } = string.Empty;
public string Moonrise { get; set; } public string Moonrise { get; set; } = string.Empty;
public string Moonset { get; set; } public string Moonset { get; set; } = string.Empty;
public string Moon_Phase { get; set; } public string Moon_Phase { get; set; } = string.Empty;
public double? Moon_Illumination { get; set; } public double? Moon_Illumination { get; set; }
} }
public class AirQuality public class AirQuality
{ {
public double Co { get; set; } public double Co { get; set; } = 0;
public double No2 { get; set; } public double No2 { get; set; } = 0;
public double O3 { get; set; } public double O3 { get; set; } = 0;
public double So2 { get; set; } public double So2 { get; set; } = 0;
public double Pm2_5 { get; set; } public double Pm2_5 { get; set; } = 0;
public double Pm10 { get; set; } public double Pm10 { get; set; } = 0;
public int Us_Epa_Index { get; set; } public int Us_Epa_Index { get; set; } = 0;
public int Gb_Defra_Index { get; set; } public int Gb_Defra_Index { get; set; } = 0;
} }
public class WeatherSummary public class WeatherSummary
{ {
public string ConditionText { get; set; } public string ConditionText { get; set; } = string.Empty;
public string ConditionIcon { get; set; } public string ConditionIcon { get; set; } = string.Empty;
public double AvgTempC { get; set; } public double AvgTempC { get; set; } = 0;
public double AvgFeelslikeC { get; set; } public double AvgFeelslikeC { get; set; } = 0;
public int TotalChanceOfRain { get; set; } public int TotalChanceOfRain { get; set; } = 0;
public int TotalChanceOfSnow { get; set; } public int TotalChanceOfSnow { get; set; } = 0;
} }

View File

@@ -1,8 +1,12 @@
using HomeApi.Registration; using HomeApi.Registration;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging(loggingBuilder =>
{
loggingBuilder.AddSeq(builder.Configuration.GetSection("Seq"));
});
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>()); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddControllers(); builder.Services.AddControllers();
@@ -22,4 +26,4 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@@ -12,6 +12,14 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Seq": {
"ServerUrl": "https://log.azaaxin.com",
"ApiKey": "KEY",
"MinimumLevel": "Trace",
"LevelOverride": {
"Microsoft": "Warning"
}
},
"ApiConfiguration": { "ApiConfiguration": {
"EspConfiguration": { "EspConfiguration": {
"InformationBoardImageUrl": "http://192.168.101.178:5000/home/default.jpg", "InformationBoardImageUrl": "http://192.168.101.178:5000/home/default.jpg",

View File

@@ -12,6 +12,14 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Seq": {
"ServerUrl": "https://log.azaaxin.com",
"ApiKey": "KEY",
"MinimumLevel": "Trace",
"LevelOverride": {
"Microsoft": "Warning"
}
},
"ApiConfiguration": { "ApiConfiguration": {
"EspConfiguration": { "EspConfiguration": {
"InformationBoardImageUrl": "http://192.168.101.178:5000/home/default.jpg", "InformationBoardImageUrl": "http://192.168.101.178:5000/home/default.jpg",

View File

@@ -43,8 +43,11 @@
return "fa-question-circle"; return "fa-question-circle";
} }
private string GetAirQualityStatus(AirQuality data) private string GetAirQualityStatus(AirQuality? data)
{ {
if(data == null)
return "No Data";
var highestAqi = Math.Max(data.Us_Epa_Index, data.Gb_Defra_Index); var highestAqi = Math.Max(data.Us_Epa_Index, data.Gb_Defra_Index);
return highestAqi switch return highestAqi switch
@@ -487,12 +490,14 @@
<div class="current-weather"> <div class="current-weather">
<div class="location"> <div class="location">
<i class="fas fa-map-marker-alt location-icon"></i> <i class="fas fa-map-marker-alt location-icon"></i>
<span class="location-text">@Model.Weather.CityName</span> <span class="location-text">@(Model.Weather?.CityName ?? "Failed to fetch data")</span>
</div> </div>
<div class="current-temp"> <div class="current-temp">
<div class="temp-main">@Model.Weather.Current.Temperature°C</div> <div class="temp-main">
<div class="feels-like">Feels like @Model.Weather.Current.Feelslike°C</div> @(Model.Weather?.Current.Temperature != null ? $"{Model.Weather.Current.Temperature}°C" : "N/A")
</div>
<div class="feels-like">Feels like @Model.Weather?.Current.Feelslike°C</div>
</div> </div>
<div class="weather-details"> <div class="weather-details">
@@ -501,7 +506,7 @@
<i class="fas fa-cloud cloud-icon"></i> <i class="fas fa-cloud cloud-icon"></i>
<span>Clouds</span> <span>Clouds</span>
</div> </div>
<span class="detail-value">@Model.Weather.Current.Cloud%</span> <span class="detail-value">@Model?.Weather?.Current.Cloud%</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@@ -509,7 +514,7 @@
<i class="fas fa-wind wind-icon"></i> <i class="fas fa-wind wind-icon"></i>
<span>Wind</span> <span>Wind</span>
</div> </div>
<span class="detail-value">@Model.Weather.Current.WindPerMeterSecond.ToString("0.##") m/s @Model.Weather.Current.WindDirection</span> <span class="detail-value">@Model?.Weather?.Current.WindPerMeterSecond.ToString("0.##") m/s @Model?.Weather?.Current.WindDirection</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@@ -517,7 +522,7 @@
<i class="fas fa-chart-line activity-icon"></i> <i class="fas fa-chart-line activity-icon"></i>
<span>Gusts</span> <span>Gusts</span>
</div> </div>
<span class="detail-value">@Model.Weather.Current.WindGustPerMeterSecond.ToString("0.##") m/s</span> <span class="detail-value">@Model?.Weather?.Current.WindGustPerMeterSecond.ToString("0.##") m/s</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@@ -525,21 +530,21 @@
<i class="fas fa-star aurora-icon"></i> <i class="fas fa-star aurora-icon"></i>
<span>Aurora</span> <span>Aurora</span>
</div> </div>
<span class="detail-value">@Model.Weather.Current.AuroraProbability.Value%</span> <span class="detail-value">@Model?.Weather?.Current.AuroraProbability.Value%</span>
</div> </div>
</div> </div>
<div class="air-quality-badge"> <div class="air-quality-badge">
Air Quality: @GetAirQualityStatus(Model.Weather.Current.AirQuality) Air Quality: @GetAirQualityStatus(Model?.Weather?.Current.AirQuality)
</div> </div>
</div> </div>
<!-- Middle Column - Forecast --> <!-- Middle Column - Forecast -->
<div class="forecast-section"> <div class="forecast-section">
<h3 class="section-title">@Model.Weather.Forecast.Count-Day Forecast</h3> <h3 class="section-title">@Model?.Weather?.Forecast.Count-Day Forecast</h3>
<div class="forecast-grid"> <div class="forecast-grid">
@foreach (var day in Model.Weather.Forecast) @foreach (var day in Model?.Weather?.Forecast ?? Enumerable.Empty<Forecast>())
{ {
<div class="forecast-card"> <div class="forecast-card">
<div class="forecast-date">@GetDayStatus(day.Date)</div> <div class="forecast-date">@GetDayStatus(day.Date)</div>
@@ -559,7 +564,7 @@
<i class="fas fa-sun sunrise-icon"></i> <i class="fas fa-sun sunrise-icon"></i>
<span>Sunrise</span> <span>Sunrise</span>
</div> </div>
<span class="sun-moon-value">@Model.Weather.Forecast[0].Astro.Sunrise</span> <span class="sun-moon-value">@Model?.Weather?.Forecast[0].Astro.Sunrise</span>
</div> </div>
<div class="sun-moon-item"> <div class="sun-moon-item">
@@ -567,7 +572,7 @@
<i class="fas fa-sun sunset-icon"></i> <i class="fas fa-sun sunset-icon"></i>
<span>Sunset</span> <span>Sunset</span>
</div> </div>
<span class="sun-moon-value">@Model.Weather.Forecast[0].Astro.Sunset</span> <span class="sun-moon-value">@Model?.Weather?.Forecast[0].Astro.Sunset</span>
</div> </div>
<div class="sun-moon-item"> <div class="sun-moon-item">
@@ -575,7 +580,7 @@
<i class="fas fa-moon moon-icon"></i> <i class="fas fa-moon moon-icon"></i>
<span>Moonrise</span> <span>Moonrise</span>
</div> </div>
<span class="sun-moon-value">@Model.Weather.Forecast[0].Astro.Moonrise</span> <span class="sun-moon-value">@Model?.Weather?.Forecast[0].Astro.Moonrise</span>
</div> </div>
<div class="sun-moon-item"> <div class="sun-moon-item">
@@ -583,12 +588,12 @@
<i class="fas fa-moon moon-icon"></i> <i class="fas fa-moon moon-icon"></i>
<span>Moonset</span> <span>Moonset</span>
</div> </div>
<span class="sun-moon-value">@Model.Weather.Forecast[0].Astro.Moonset</span> <span class="sun-moon-value">@Model?.Weather?.Forecast[0].Astro.Moonset</span>
</div> </div>
<div class="sun-moon-item"> <div class="sun-moon-item">
<span class="moon-phase-label">Moon phase</span> <span class="moon-phase-label">Moon phase</span>
<span class="sun-moon-value">@Model.Weather.Forecast[0].Astro.Moon_Illumination%</span> <span class="sun-moon-value">@Model?.Weather?.Forecast[0].Astro.Moon_Illumination%</span>
</div> </div>
</div> </div>
</div> </div>
@@ -598,7 +603,7 @@
<h3 class="section-title">Upcoming Departures</h3> <h3 class="section-title">Upcoming Departures</h3>
<div class="transport-list"> <div class="transport-list">
@foreach (var transport in Model.TimeTable.Take(5)) @foreach (var transport in Model?.TimeTable?.Take(5) ?? Enumerable.Empty<TimeTable>())
{ {
var departureTime = DateTime.Parse(transport.DepartureTime); var departureTime = DateTime.Parse(transport.DepartureTime);

View File

@@ -1,3 +0,0 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

View File

@@ -1,15 +1,29 @@
[![myxelium - homescreen](https://img.shields.io/static/v1?label=myxelium&message=homescreen&color=purple&logo=github)](https://github.com/myxelium/homescreen "Go to GitHub repo") [![myxelium - Wireless_Eink_HomeScreen](https://img.shields.io/static/v1?label=myxelium&message=Wireless_Eink_HomeScreen&color=purple&logo=github)](https://github.com/myxelium/Wireless_Eink_HomeScreen "Go to GitHub repo")
[![stars - homescreen](https://img.shields.io/github/stars/myxelium/homescreen?style=social)](https://github.com/myxelium/homescreen) [![stars - Wireless_Eink_HomeScreen](https://img.shields.io/github/stars/myxelium/Wireless_Eink_HomeScreen?style=social)](https://github.com/myxelium/Wireless_Eink_HomeScreen)
[![forks - homescreen](https://img.shields.io/github/forks/myxelium/homescreen?style=social)](https://github.com/myxelium/homescreen) [![forks - Wireless_Eink_HomeScreen](https://img.shields.io/github/forks/myxelium/Wireless_Eink_HomeScreen?style=social)](https://github.com/myxelium/Wireless_Eink_HomeScreen)
[![GitHub tag](https://img.shields.io/github/tag/myxelium/homescreen?include_prereleases=&sort=semver&color=purple)](https://github.com/myxelium/homescreen/releases/) [![GitHub tag](https://img.shields.io/github/tag/myxelium/Wireless_Eink_HomeScreen?include_prereleases=&sort=semver&color=purple)](https://github.com/myxelium/Wireless_Eink_HomeScreen/releases/)
[![License](https://img.shields.io/badge/License-GPL-purple)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/License-GPL-purple)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![issues - homescreen](https://img.shields.io/github/issues/myxelium/homescreen)](https://github.com/myxelium/homescreen/issues) [![issues - Wireless_Eink_HomeScreen](https://img.shields.io/github/issues/myxelium/Wireless_Eink_HomeScreen)](https://github.com/myxelium/Wireless_Eink_HomeScreen/issues)
[![Build and Deploy](https://github.com/Myxelium/HomeScreen/actions/workflows/build.yml/badge.svg)](https://github.com/Myxelium/HomeScreen/actions/workflows/build.yml) [![Build and Deploy](https://github.com/Myxelium/Wireless_Eink_HomeScreen/actions/workflows/build.yml/badge.svg)](https://github.com/Myxelium/Wireless_Eink_HomeScreen/actions/workflows/build.yml)
# This # What why how
Core api and [Esp32 (Microcontroller)](https://en.wikipedia.org/wiki/ESP32) code for displaying weather data and public transport information on a e-ink display. This is a project I created that pulls weather data from the internet, transforms it into custom images, and displays them on an e-ink screen powered by an ESP32.
## What This Project Does
I wanted a low-power way to see weather information at a glance, so I built this system that:
- Fetches real-time weather data from online APIs
- Processes and converts the data into visual images (temperature graphs, forecast icons, etc.)
- Sends these images wirelessly to an ESP32 microcontroller
- Displays the information on an energy-efficient e-ink screen
- Updates periodically while consuming minimal power
<img width="800" height="480" alt="image" src="https://github.com/user-attachments/assets/ef5af0c6-ea3a-494d-b2af-3de6e70b3e6a" /> <img width="800" height="480" alt="image" src="https://github.com/user-attachments/assets/ef5af0c6-ea3a-494d-b2af-3de6e70b3e6a" />
# How do I get to use this without programming knowledge?
Check in the wiki for the guide how to get everything working!
### 😺 [Wiki Get Started Guide](https://github.com/Myxelium/Wireless_Eink_HomeScreen/wiki/)
## Git Notes ## Git Notes
All commits has to follow this [Conventional Commits style](https://www.conventionalcommits.org/) to pass the pipeline. All commits has to follow this [Conventional Commits style](https://www.conventionalcommits.org/) to pass the pipeline.
## Features 😺 ## Features 😺
@@ -128,4 +142,4 @@ Install following libraries (if more is needed search for them and install them
* GUI_Paint * GUI_Paint
* JPEGDEC * JPEGDEC
You need the Waveshare examples installed since it uses code from them. See above link to find the download. You need the Waveshare examples installed since it uses code from them download them here [Download](https://files.waveshare.com/upload/5/50/E-Paper_ESP32_Driver_Board_Code.7z) or check above link.