Files
MontaukOS/programs/src/weather/main.cpp
T

489 lines
18 KiB
C++

/*
* main.cpp
* MontaukOS Weather app - standalone Window Server process
* Fetches current weather from wttr.in via HTTPS (BearSSL)
* Displays temperature, description, feels like, and location
* Copyright (c) 2026 Daniel Hammer
*/
#include <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/heap.h>
#include <gui/gui.hpp>
#include <gui/standalone.hpp>
#include <gui/svg.hpp>
#include <gui/truetype.hpp>
#include <gui/mtk.hpp>
#include <tls/tls.hpp>
extern "C" {
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
}
using namespace gui;
using namespace gui::colors;
// ============================================================================
// Constants
// ============================================================================
static constexpr int INIT_W = 380;
static constexpr int INIT_H = 280;
static constexpr int HEADER_H = 160;
static constexpr int FOOTER_H = 50;
static constexpr int ICON_SIZE = 80;
static constexpr int ICON_X = 28;
static constexpr int ICON_Y = 40;
static constexpr int INFO_X = ICON_X + ICON_SIZE + 20; // 128
static constexpr int TEMP_Y = 40;
static int TEMP_SIZE = 40;
static constexpr int DESC_Y = 92;
static int DESC_SIZE = 17;
static constexpr int FEELS_Y = 116;
static int LABEL_SIZE = 15;
static constexpr int RESP_MAX = 65536;
static const char WTTR_HOST[] = "wttr.in";
// ============================================================================
// App state
// ============================================================================
enum class AppPhase { IDLE, LOADING, DONE, ERR };
static AppPhase g_phase = AppPhase::IDLE;
static char g_temp[32] = {};
static char g_desc[128] = {};
static char g_feels[48] = {};
static char g_location[128] = {};
static char g_status[256] = {};
static int g_win_w = INIT_W;
static int g_win_h = INIT_H;
static char* g_resp_buf = nullptr;
// Fonts
static TrueTypeFont* g_font = nullptr; // Roboto Medium
static TrueTypeFont* g_font_bold = nullptr; // Roboto Bold
// Weather icon (loaded per-fetch based on weather code)
static SvgIcon g_icon = {nullptr, 0, 0};
static char g_icon_name[64] = {};
// TLS state (lazy-init on first fetch)
static bool g_tls_ready = false;
static uint32_t g_server_ip = 0;
static tls::TrustAnchors g_tas = {nullptr, 0, 0};
// ============================================================================
// HTTP parsing
// ============================================================================
static int find_header_end(const char* buf, int len) {
for (int i = 0; i + 3 < len; i++)
if (buf[i]=='\r' && buf[i+1]=='\n' && buf[i+2]=='\r' && buf[i+3]=='\n')
return i + 4;
return -1;
}
static int parse_status_code(const char* buf, int len) {
int i = 0;
while (i < len && buf[i] != ' ') i++;
if (i >= len || i + 3 >= len) return -1;
i++;
if (buf[i] < '0' || buf[i] > '9') return -1;
return (buf[i]-'0')*100 + (buf[i+1]-'0')*10 + (buf[i+2]-'0');
}
// ============================================================================
// JSON parsing
// ============================================================================
// Extract value of a simple JSON string field: "key":"value"
static int extract_json_string(const char* buf, int len, const char* key,
char* out, int maxOut) {
int klen = (int)strlen(key);
for (int i = 0; i < len - klen - 3; i++) {
if (buf[i] != '"') continue;
if (memcmp(buf + i + 1, key, klen) != 0) continue;
if (buf[i + 1 + klen] != '"') continue;
if (buf[i + 2 + klen] != ':') continue;
int p = i + 3 + klen;
while (p < len && (buf[p]==' ' || buf[p]=='\t')) p++;
if (p >= len || buf[p] != '"') continue;
p++;
int j = 0;
while (p < len && j < maxOut - 1 && buf[p] != '"') {
if (buf[p] == '\\' && p + 1 < len) { p++; out[j++] = buf[p]; }
else out[j++] = buf[p];
p++;
}
out[j] = '\0';
return j;
}
out[0] = '\0';
return 0;
}
// Find the first occurrence of needle in buf[0..len-1]. Returns offset or -1.
static int find_substr(const char* buf, int len, const char* needle) {
int nlen = (int)strlen(needle);
if (nlen > len) return -1;
for (int i = 0; i <= len - nlen; i++) {
if (memcmp(buf + i, needle, nlen) == 0) return i;
}
return -1;
}
// Extract the "value" string within a named JSON array-of-objects field.
// e.g., "weatherDesc":[{"value":"Partly cloudy"}] → "Partly cloudy"
// Works by finding section_key first, then the first "value" after it.
static int extract_array_value(const char* buf, int len, const char* section_key,
char* out, int maxOut) {
int pos = find_substr(buf, len, section_key);
if (pos < 0) { out[0] = '\0'; return 0; }
return extract_json_string(buf + pos, len - pos, "value", out, maxOut);
}
// Parse a signed integer from a decimal string.
static int parse_int(const char* s) {
int sign = 1, val = 0, i = 0;
if (s[0] == '-') { sign = -1; i = 1; }
while (s[i] >= '0' && s[i] <= '9') val = val * 10 + (s[i++] - '0');
return sign * val;
}
// ============================================================================
// Weather code → icon filename
// Maps wttr.in WMO-style weather codes to Flat-Remix panel icon names.
// ============================================================================
static const char* weather_code_to_icon(int code) {
switch (code) {
case 113: return "weather-clear.svg";
case 116: return "weather-few-clouds.svg";
case 119: return "weather-clouds.svg";
case 122: return "weather-overcast.svg";
case 143: return "weather-mist.svg";
case 248: case 260: return "weather-fog.svg";
case 176: case 263: case 266: case 353: return "weather-showers-scattered.svg";
case 293: case 296: case 299: case 302:
case 305: case 308: case 356: case 359: return "weather-showers.svg";
case 179: case 362: case 365: case 368: return "weather-snow-scattered.svg";
case 323: case 326: case 329: case 332:
case 335: case 338: case 371: case 374: return "weather-snow.svg";
case 227: case 230: return "weather-snow.svg";
case 182: case 311: case 314: case 317: case 320: return "weather-snow-rain.svg";
case 185: case 281: case 284: return "weather-freezing-rain.svg";
case 350: case 377: return "weather-hail.svg";
case 200: case 386: case 389: case 392: case 395: return "weather-storm.svg";
default: return "weather-none-available.svg";
}
}
// Load the weather icon for the given icon filename (caches the last result).
static void load_weather_icon(const char* icon_name) {
if (strcmp(g_icon_name, icon_name) == 0 && g_icon.pixels) return;
if (g_icon.pixels) svg_free(g_icon);
static char path[80];
snprintf(path, sizeof(path), "0:/icons/%s", icon_name);
// ColorScheme-Text paths (main shape) use this fill_color; accent classes
// (ColorScheme-Highlight=blue, ColorScheme-NeutralText=yellow, etc.) use their
// CSS-defined colors as parsed from the SVG's <style> block by svg_load.
static constexpr Color ICON_FG = Color::from_rgb(0x5C, 0x61, 0x6C); // dark neutral
g_icon = svg_load(path, ICON_SIZE, ICON_SIZE, ICON_FG);
int i = 0;
while (icon_name[i] && i < 63) { g_icon_name[i] = icon_name[i]; i++; }
g_icon_name[i] = '\0';
}
// ============================================================================
// UI scale
// ============================================================================
static void apply_scale(int scale) {
switch (scale) {
case 0: TEMP_SIZE=32; DESC_SIZE=14; LABEL_SIZE=12; break;
case 2: TEMP_SIZE=50; DESC_SIZE=21; LABEL_SIZE=19; break;
default: TEMP_SIZE=40; DESC_SIZE=17; LABEL_SIZE=15; break;
}
}
// ============================================================================
// Network fetch (blocking — called from the event loop)
// ============================================================================
static void do_fetch() {
// Lazy init: resolve DNS and load CA certificates once
if (!g_tls_ready) {
g_server_ip = montauk::resolve(WTTR_HOST);
if (g_server_ip == 0) {
snprintf(g_status, sizeof(g_status),
"Error: could not resolve %s", WTTR_HOST);
g_phase = AppPhase::ERR; return;
}
g_tas = tls::load_trust_anchors();
if (g_tas.count == 0) {
snprintf(g_status, sizeof(g_status), "Error: no CA certificates loaded");
g_phase = AppPhase::ERR; return;
}
g_tls_ready = true;
}
static char request[512];
int reqLen = snprintf(request, sizeof(request),
"GET /?format=j1 HTTP/1.0\r\n"
"Host: %s\r\n"
"User-Agent: MontaukOS/1.0 weather\r\n"
"Accept: application/json\r\n"
"Connection: close\r\n"
"\r\n",
WTTR_HOST);
int respLen = tls::https_fetch(WTTR_HOST, g_server_ip, 443,
request, reqLen, g_tas,
g_resp_buf, RESP_MAX);
if (respLen <= 0) {
snprintf(g_status, sizeof(g_status), "Error: no response from server");
g_phase = AppPhase::ERR; return;
}
g_resp_buf[respLen] = '\0';
int headerEnd = find_header_end(g_resp_buf, respLen);
if (headerEnd < 0) {
snprintf(g_status, sizeof(g_status), "Error: malformed HTTP response");
g_phase = AppPhase::ERR; return;
}
int status = parse_status_code(g_resp_buf, headerEnd);
if (status != 200) {
snprintf(g_status, sizeof(g_status), "Error: HTTP %d from server", status);
g_phase = AppPhase::ERR; return;
}
const char* body = g_resp_buf + headerEnd;
int bodyLen = respLen - headerEnd;
// Extract core weather fields
static char temp_raw[16], feels_raw[16], code_raw[8];
extract_json_string(body, bodyLen, "temp_C", temp_raw, sizeof(temp_raw));
extract_json_string(body, bodyLen, "FeelsLikeC", feels_raw, sizeof(feels_raw));
extract_json_string(body, bodyLen, "weatherCode", code_raw, sizeof(code_raw));
extract_array_value(body, bodyLen, "\"weatherDesc\"", g_desc, sizeof(g_desc));
static char area[64], country[64];
extract_array_value(body, bodyLen, "\"areaName\"", area, sizeof(area));
extract_array_value(body, bodyLen, "\"country\"", country, sizeof(country));
// Degree sign is U+00B0 = 0xB0 in Latin-1 (single byte, within the 256-entry glyph cache)
snprintf(g_temp, sizeof(g_temp), "%s\xb0""C", temp_raw);
snprintf(g_feels, sizeof(g_feels), "Feels like: %s\xb0""C", feels_raw);
if (area[0] && country[0])
snprintf(g_location, sizeof(g_location), "%s, %s", area, country);
else if (area[0])
snprintf(g_location, sizeof(g_location), "%s", area);
else
snprintf(g_location, sizeof(g_location), "Unknown location");
// Load matching weather icon
int code = parse_int(code_raw);
load_weather_icon(weather_code_to_icon(code));
g_phase = AppPhase::DONE;
}
// ============================================================================
// Theme colors
// ============================================================================
static constexpr Color ERR_TEXT = Color::from_rgb(0xCC, 0x22, 0x22);
static mtk::Theme weather_theme() {
mtk::Theme theme = mtk::make_theme();
theme.window_bg = WHITE;
theme.surface = Color::from_rgb(0xF5, 0xF5, 0xF5);
theme.surface_alt = Color::from_rgb(0xF8, 0xF8, 0xF8);
theme.surface_hover = mtk::mix(theme.surface, theme.accent, 18);
theme.border = Color::from_rgb(0xCC, 0xCC, 0xCC);
theme.text = Color::from_rgb(0x33, 0x33, 0x33);
theme.text_muted = Color::from_rgb(0x88, 0x88, 0x88);
theme.text_subtle = Color::from_rgb(0x99, 0x99, 0x99);
return theme;
}
// ============================================================================
// Rendering
// ============================================================================
static constexpr int BTN_W = 110;
static constexpr int BTN_H = 30;
static int g_mouse_x = -1;
static int g_mouse_y = -1;
static Rect refresh_button_rect() {
int btn_x = (g_win_w - BTN_W) / 2;
int btn_y = g_win_h - FOOTER_H + (FOOTER_H - BTN_H) / 2;
return {btn_x, btn_y, BTN_W, BTN_H};
}
static void render(Canvas& canvas) {
mtk::Theme theme = weather_theme();
Rect btn = refresh_button_rect();
// ── Background ───────────────────────────────────────────────────────────
canvas.fill_rect(0, 0, g_win_w, g_win_h - FOOTER_H, theme.window_bg);
canvas.fill_rect(0, g_win_h - FOOTER_H, g_win_w, FOOTER_H, theme.surface);
// Divider between content and location strip
canvas.hline(0, HEADER_H, g_win_w, theme.border);
// Divider above footer
canvas.hline(0, g_win_h - FOOTER_H, g_win_w, theme.border);
if (!g_font) return;
// ── Main content area (y=0..HEADER_H) ────────────────────────────────────
if (g_phase == AppPhase::LOADING) {
draw_text(canvas, g_font, 20, HEADER_H / 2 - 9,
"Fetching weather data...", theme.text_subtle, 18);
} else if (g_phase == AppPhase::ERR) {
draw_text(canvas, g_font, 20, 20, g_status, ERR_TEXT, 15);
} else if (g_phase == AppPhase::IDLE) {
draw_text(canvas, g_font, 20, HEADER_H / 2 - 9,
"Click Refresh to check weather.", theme.text_subtle, 18);
} else { // DONE
// Weather icon
draw_icon(canvas, ICON_X, ICON_Y, g_icon);
// Temperature (large bold)
TrueTypeFont* temp_font = g_font_bold ? g_font_bold : g_font;
draw_text(canvas, temp_font, INFO_X, TEMP_Y, g_temp, theme.text, TEMP_SIZE);
// Weather description
draw_text(canvas, g_font, INFO_X, DESC_Y, g_desc, theme.text, DESC_SIZE);
// Feels like
draw_text(canvas, g_font, INFO_X, FEELS_Y, g_feels, theme.text_muted, LABEL_SIZE);
}
// ── Location strip (y=HEADER_H..g_win_h-FOOTER_H) ────────────────────────
if (g_phase == AppPhase::DONE) {
draw_text(canvas, g_font, 20, HEADER_H + 14, g_location, theme.text, LABEL_SIZE);
}
// ── Refresh button (rounded, in footer) ───────────────────────────────────
bool hovered = btn.contains(g_mouse_x, g_mouse_y);
bool loading = (g_phase == AppPhase::LOADING);
mtk::WidgetState state = mtk::widget_state(false, hovered, !loading);
mtk::draw_button(canvas, btn, loading ? "Loading..." : "Refresh",
mtk::BUTTON_PRIMARY, state, theme);
}
// ============================================================================
// Entry point
// ============================================================================
extern "C" void _start() {
if (!fonts::init())
montauk::exit(1);
// Allocate response buffer from heap
g_resp_buf = (char*)malloc(RESP_MAX + 1);
if (!g_resp_buf) montauk::exit(1);
// Load fonts
auto load_font = [](const char* path) -> TrueTypeFont* {
TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont));
if (!f) return nullptr;
montauk::memset(f, 0, sizeof(TrueTypeFont));
if (!f->init(path)) { montauk::mfree(f); return nullptr; }
return f;
};
g_font = load_font("0:/fonts/Roboto-Medium.ttf");
g_font_bold = load_font("0:/fonts/Roboto-Bold.ttf");
if (!g_font) montauk::exit(1);
apply_scale(montauk::win_getscale());
WsWindow win;
if (!win.create("Weather", INIT_W, INIT_H))
montauk::exit(1);
mtk::StandaloneHost host(&win);
Canvas canvas = host.canvas();
// Initial fetch on startup
g_phase = AppPhase::LOADING;
render(canvas);
host.present();
do_fetch();
// Event loop
while (true) {
montauk::abi::WinEvent ev;
int r = win.poll(&ev);
if (r < 0) break;
if (r == 0) {
montauk::sleep_ms(16);
canvas = host.canvas();
render(canvas);
host.present();
continue;
}
if (ev.type == 3) break; // close
if (ev.type == 4) {
apply_scale(win.scale_factor);
}
if (ev.type == 2) {
g_win_w = win.width;
g_win_h = win.height;
canvas = host.canvas();
}
if (ev.type == 1) {
// Track mouse position for hover states
g_mouse_x = ev.mouse.x;
g_mouse_y = ev.mouse.y;
// Mouse — check for Refresh button click
bool just_clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1);
if (just_clicked && g_phase != AppPhase::LOADING) {
Rect btn = refresh_button_rect();
int mx = ev.mouse.x, my = ev.mouse.y;
if (btn.contains(mx, my)) {
g_phase = AppPhase::LOADING;
canvas = host.canvas();
render(canvas);
host.present();
do_fetch();
}
}
}
canvas = host.canvas();
render(canvas);
host.present();
}
if (g_icon.pixels) svg_free(g_icon);
win.destroy();
montauk::exit(0);
}