Skip to content

weather.tc

weather.tc — 16-day weather + solar-yield + wind forecast from Open-Meteo (FREE, no key)

Source on GitHub

// weather.tc — 16-day weather + solar-yield + wind forecast from Open-Meteo (FREE, no key)
//
// Location is auto-detected on first boot (IP geolocation, approximate) and can be set
// exactly by the user. All over plain HTTP (no TLS). Shows temperature, rain, estimated
// PV yield and wind as four 16-day charts on the device's main page, plus a summary.
//
//   WX loc <city>        set location by name  (e.g. `WX loc Köln`) — geocoded, exact
//   WX coord <lat> <lon> set location by coordinates  (e.g. `WX coord 50.94 6.96`)
//   WX auto              re-run IP auto-detect
//   WX                   force an immediate refetch
//
// APIs (all free, no key, work over http://):
//   forecast   api.open-meteo.com        (DWD ICON model for Germany; up to 16 days)
//   geocoding  geocoding-api.open-meteo.com   (city name -> lat/lon)
//   ip locate  ip-api.com                (public-IP -> approximate lat/lon/city)
//
// Solar yield is ESTIMATED from shortwave_radiation_sum (global horizontal irradiance):
//     yield_kWh ≈ GHI[MJ/m²] / 3.6 * KWP * PR     — tune KWP/PR to your array.

#define DEF_LAT  "50.94"       // neutral default (Köln); auto-detect or `WX loc` overrides
#define DEF_LON  "6.96"
#define DEF_CITY "Köln"
#define KWP      10.0          // your PV peak power [kWp]
#define PR       0.80          // performance ratio (system losses)
#define POLL_H   6             // re-fetch every N hours
#define NDAYS    16

// ── location (persisted) ───────────────────────────────────────────────────
persist char p_lat[14];        // current latitude string
persist char p_lon[14];        // current longitude string
persist char p_city[40];       // town name
persist int  loc_set;          // 0 = auto-detect mode, 1 = user-fixed
int want_geoip;                // queue an IP auto-detect (set in main / by `WX auto`)
char want_geocode[40];         // queue a geocode of this city name (set by `WX loc`)

// ── forecast arrays: index 0 = today ... index 15 = +15 days ───────────────
float f_tmax[NDAYS];
float f_tmin[NDAYS];
float f_precip[NDAYS];         // [mm]
float f_rad[NDAYS];            // shortwave radiation sum [MJ/m²]
float f_yield[NDAYS];          // estimated PV yield [kWh]
float f_wind[NDAYS];           // max wind [km/h]
float f_gust[NDAYS];           // max gusts [km/h]
float f_wdir[NDAYS];           // dominant direction [deg]
int   valid;
int   fetched_at;             // tasm_uptime [s] of last weather fetch (-1 = never)

// ── work buffers (global — HTTP bodies up to ~1.4 KB, URL ~249 B) ──────────
char w_resp[1800];
char w_url[280];
char w_chunk[420];
char w_arr[400];
char w_tok[24];
char w_compass[24];

// ── KWP / PR config (read from a file if present; KWP/PR are the defaults) ──
#define CFG_FILE "/weather.cfg"
float kwp = KWP;               // PV peak power [kWp] — overridden from CFG_FILE
float pr  = PR;                // performance ratio  — overridden from CFG_FILE
int   ndays = NDAYS;           // days to fetch/show — from CFG_FILE, clamped 4..NDAYS
int   vdays = NDAYS;           // days with REAL data (<= ndays) — trailing open-meteo nulls dropped
char  cfgbuf[256];             // config file buffer (room for // comment lines)
char  cfgline[64];             // one config line at a time
char  cfg_city[40];            // CITY= from the cfg — geocoded, overrides IP auto-detect

// JS hook: line charts default to bare-index x-ticks over a >7-day span; relabel
// them with the same "DD.MM." dates the column (rain) chart shows. Runs in the
// chart draw scope (dt=DataTable col0=datetime, o=options).
char  jsdate[220];

// Parse NDAYS comma-separated numbers from the JSON array following `key`. `key`
// must include `":["` so it matches the data array, not "daily_units" (`":"unit"`).
// Returns the number of REAL (non-null) values parsed. Open-Meteo emits `null`
// for forecast days beyond the model horizon (e.g. day 16 for some locations —
// mi-hol's Geilenkirchen: "…,34.1,28.4,null"). atof("null") would be 0.0 and
// plot a bogus zero at the right edge; instead we stop at the first null so the
// caller can shrink the chart to the days that actually have data.
int parse16(char json[], char key[], float out[]) {
    int i = 0;
    while (i < NDAYS) { out[i] = 0.0; i = i + 1; }
    int p = strFind(json, key);
    if (p < 0) { return 0; }
    strSub(w_chunk, json, p + strlen(key), 380);
    int rb = strFind(w_chunk, "]");
    if (rb < 0) { return 0; }
    strSub(w_arr, w_chunk, 0, rb);
    i = 1;
    while (i <= NDAYS) {
        int n = strToken(w_tok, w_arr, ',', i);
        if (n <= 0) { break; }
        if (w_tok[0] == 'n') { break; }   // "null" -> forecast horizon reached
        out[i - 1] = atof(w_tok);
        i = i + 1;
    }
    return i - 1;
}

// Wind direction (degrees) -> 8-point German compass.
void compass(int deg, char out[]) {
    strcpy(w_compass, "N|NO|O|SO|S|SW|W|NW");
    int idx = ((deg + 22) / 45) % 8;
    strToken(out, w_compass, '|', idx + 1);
}

// IP auto-detect: public IP -> approximate lat/lon/city.
void do_geoip() {
    int r = httpGet("http://ip-api.com/json/?fields=status,lat,lon,city", w_resp);
    if (r <= 0) { addLog("weather: geoip failed (%d)", r); return; }
    float la = jsonNum(w_resp, "lat");
    float lo = jsonNum(w_resp, "lon");
    if (la == 0.0 || lo == 0.0) { addLog("weather: geoip no fix"); return; }
    sprintf(p_lat, "%.4f", la);
    sprintf(p_lon, "%.4f", lo);
    jsonStr(w_resp, "city", p_city);
    fetched_at = -1;                       // force a weather refetch for the new spot
    saveVars();
    addLog("weather: auto-located %s (%s, %s)", p_city, p_lat, p_lon);
}

// City name -> exact lat/lon via Open-Meteo geocoding. Sets loc_set=1 (user-fixed).
void do_geocode(char name[]) {
    strReplace(name, " ", "%20");          // URL-encode spaces (e.g. "Bad Honnef")
    sprintf(w_url, "http://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=de", name);
    int r = httpGet(w_url, w_resp);
    if (r <= 0) { addLog("weather: geocode failed (%d)", r); return; }
    int pla = strFind(w_resp, "latitude\":");
    int plo = strFind(w_resp, "longitude\":");
    if (pla < 0 || plo < 0) { addLog("weather: '%s' not found", name); return; }
    strSub(w_chunk, w_resp, pla + 10, 14); sprintf(p_lat, "%.4f", atof(w_chunk));
    strSub(w_chunk, w_resp, plo + 11, 14); sprintf(p_lon, "%.4f", atof(w_chunk));
    strReplace(name, "%20", " ");          // restore for display
    strcpy(p_city, name);
    loc_set = 1;
    want_geoip = 0;                        // user picked a location — cancel pending auto-detect
    fetched_at = -1;
    saveVars();
    addLog("weather: set %s -> %s, %s", p_city, p_lat, p_lon);
}

// Read KWP / PR from CFG_FILE if it exists (format: "KWP=10.0" / "PR=0.80",
// one per line). Missing keys keep the compiled defaults. Re-read on every fetch
// so editing the file via the file manager takes effect on the next refresh.
void read_cfg() {
    if (fileExists(CFG_FILE) == 0) { return; }
    int h = fileOpen(CFG_FILE, 0);
    if (h < 0) { return; }
    int n = fileRead(h, cfgbuf, 254);
    fileClose(h);
    if (n <= 0) { return; }
    cfgbuf[n] = 0;
    // Parse line by line so `//` comment lines are skipped. Keys: KWP= PR= NDAYS= CITY=
    strcpy(cfg_city, "");                                    // re-derive from the current file each read
    int li = 1;
    while (strToken(cfgline, cfgbuf, '\n', li) > 0) {
        strTrim(cfgline);
        if (cfgline[0] != '/' || cfgline[1] != '/') {       // not a // comment
            int p = 0;
            p = strFind(cfgline, "NDAYS="); if (p >= 0) { strSub(w_tok, cfgline, p + 6, 12); ndays = atoi(w_tok);
                if (ndays < 4) { ndays = 4; }
                if (ndays > NDAYS) { ndays = NDAYS; }
            }
            p = strFind(cfgline, "KWP=");  if (p >= 0) { strSub(w_tok, cfgline, p + 4, 12); kwp = atof(w_tok); }
            p = strFind(cfgline, "PR=");   if (p >= 0) { strSub(w_tok, cfgline, p + 3, 12); pr  = atof(w_tok); }
            // CITY=<name> pins the location by name (geocoded), overriding the
            // approximate IP auto-detect. Rest of the line = the city (may have
            // spaces, e.g. "Bad Honnef"). Blank/absent -> IP auto-detect as before.
            p = strFind(cfgline, "CITY="); if (p >= 0) { strSub(cfg_city, cfgline, p + 5, 39); strTrim(cfg_city); }
        }
        li = li + 1;
    }
}

void do_fetch() {
    read_cfg();
    // A CITY= edited into the cfg (then `WX`) re-geocodes without a reboot: queue
    // it if the resolved city no longer matches. TaskLoop geocodes on the next
    // cycle, which sets fetched_at=-1 and refetches with the new coords.
    if (strlen(cfg_city) > 0 && strcmp(cfg_city, p_city) != 0) { strcpy(want_geocode, cfg_city); }
    sprintf(w_url, "http://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,shortwave_radiation_sum,windspeed_10m_max,windgusts_10m_max,winddirection_10m_dominant&forecast_days=%d&timezone=auto", p_lat, p_lon, ndays);
    int r = httpGet(w_url, w_resp);
    if (r <= 0) { addLog("weather: httpGet failed (%d)", r); return; }
    // vdays = the smallest real-value count across the charted variables, so a
    // trailing null day (open-meteo horizon) is dropped from every chart at once.
    int c;
    vdays = NDAYS;
    c = parse16(w_resp, "temperature_2m_max\":[",         f_tmax);   if (c < vdays) { vdays = c; }
    c = parse16(w_resp, "temperature_2m_min\":[",         f_tmin);   if (c < vdays) { vdays = c; }
    c = parse16(w_resp, "precipitation_sum\":[",          f_precip); if (c < vdays) { vdays = c; }
    c = parse16(w_resp, "shortwave_radiation_sum\":[",    f_rad);    if (c < vdays) { vdays = c; }
    c = parse16(w_resp, "windspeed_10m_max\":[",          f_wind);   if (c < vdays) { vdays = c; }
    c = parse16(w_resp, "windgusts_10m_max\":[",          f_gust);   if (c < vdays) { vdays = c; }
    parse16(w_resp, "winddirection_10m_dominant\":[", f_wdir);   // today-only (compass), not charted
    if (vdays < 4) { vdays = ndays; }   // safety: a bad/empty response shouldn't blank the charts
    if (vdays > ndays) { vdays = ndays; }
    int i = 0;
    while (i < vdays) { f_yield[i] = f_rad[i] / 3.6 * kwp * pr; i = i + 1; }
    valid = 1;
    fetched_at = tasm_uptime;
    addLog("weather: %dd ok %s - today %.1f/%.1f C, yield ~%.0f kWh, wind %.0f/%.0f",
           ndays, p_city, f_tmax[0], f_tmin[0], f_yield[0], f_wind[0], f_gust[0]);
}

void TaskLoop() {
    delay(3000);
    while (1) {
        // tasm_net = link up (WiFi OR Ethernet). Never httpGet before the link is up.
        if (tasm_net) {
            if (strlen(want_geocode) > 0) { do_geocode(want_geocode); strcpy(want_geocode, ""); }
            if (want_geoip) { do_geoip(); want_geoip = 0; }
            if (fetched_at < 0 || (tasm_uptime - fetched_at) >= POLL_H * 3600) {
                do_fetch();
            }
        }
        delay(10000);
    }
}

float sum16(float a[]) {
    float s = 0.0; int i = 0;
    while (i < ndays) { s = s + a[i]; i = i + 1; }
    return s;
}

void WebCall() {
    char b[180];
    char wd[8];
    sprintf(b, "{s}&#127780; Wetter %s &middot; %d Tage{m}%s, %s{e}", p_city, ndays, p_lat, p_lon); webSend(b);
    sprintf(b, "{s}&#9728; PV-Anlage (cfg){m}%.1f kWp &middot; PR %.2f{e}", kwp, pr); webSend(b);
    if (valid == 0) {
        webSend("{s}{m}<span style='color:#f88'>warte auf Wetterdaten&hellip;</span>{e}");
        return;
    }
    sprintf(b, "{s}Heute{m}%.1f / %.1f &deg;C &middot; Regen %.1f mm{e}", f_tmax[0], f_tmin[0], f_precip[0]); webSend(b);
    compass((int)f_wdir[0], wd);
    sprintf(b, "{s}&#127788; Wind heute{m}%.0f km/h &middot; B&ouml;en %.0f &middot; aus %s{e}", f_wind[0], f_gust[0], wd); webSend(b);
    sprintf(b, "{s}&#9728; Solar heute (Prognose){m}%.0f kWh{e}", f_yield[0]);                                webSend(b);
    sprintf(b, "{s}&#9728; Solar %d Tage{m}%.0f kWh (&oslash; %.0f/Tag){e}", ndays, sum16(f_yield), sum16(f_yield) / ndays); webSend(b);
    sprintf(b, "{s}&#127783; Regen %d Tage{m}%.0f mm{e}", ndays, sum16(f_precip));                            webSend(b);
    sprintf(b, "{s}Aktualisiert vor{m}%d min{e}", (tasm_uptime - fetched_at) / 60);                           webSend(b);
}

void WebPage() {
    if (valid == 0) { return; }
    // Forecast is in the FUTURE: set the chart time base so index 0 = today and
    // index 15 = +15 days (x-axis runs today -> +15).
    webSend("<fieldset style='border:1px solid rgba(150,150,150,.4);border-radius:8px;margin:5px 2px;padding:2px 9px 8px'><legend style='font-size:11px;opacity:.55;padding:0 5px'>Wetterprognose</legend>");
    webSend("<div style='margin-left:-12px'>");
    WebChartTimeBase((vdays - 1) * 1440);
    WebChart(0, "Temperatur (°C)",              "Max", 0xe74c3c, 0, vdays, f_tmax, 1, 1440, 0, 0);
    WebChart(0, "",                             "Min", 0x3498db, 0, vdays, f_tmin, 1, 1440, 0, 0);
    WebChartJS(jsdate);                          // DD.MM. x-axis like the rain chart
    WebChart(1, "Regen (mm)",                   "mm",  0x3498db, 0, vdays, f_precip, 1, 1440, 0, 0);
    WebChart(1, "Solar-Ertrag Prognose (kWh)",  "kWh", 0xf39c12, 0, vdays, f_yield,  0, 1440, 0, 0);
    WebChart(0, "Wind (km/h)",                  "Max",       0x27ae60, 0, vdays, f_wind, 0, 1440, 0, 0);
    WebChart(0, "",                             "Böen",      0xe67e22, 0, vdays, f_gust, 0, 1440, 0, 0);
    WebChartJS(jsdate);                          // DD.MM. x-axis like the rain chart
    WebChartTimeBase(0);
    webSend("</div>");
    webSend("</fieldset>");
}

void Command(char cmd[]) {
    int i = 0; while (cmd[i] == ' ') { i = i + 1; }
    if (cmd[i] == 'l' && cmd[i + 1] == 'o') {                 // loc <city>
        char name[40]; strSub(name, cmd, i + 4, 0); strTrim(name);
        if (strlen(name) > 0) { strcpy(want_geocode, name); responseCmnd("geocoding queued"); }
        else { responseCmnd("usage: WX loc <city>"); }
        return;
    }
    if (cmd[i] == 'c' && cmd[i + 1] == 'o') {                 // coord <lat> <lon>
        char rest[40]; strSub(rest, cmd, i + 6, 0);
        char la[14]; char lo[14];
        strToken(la, rest, ' ', 1); strToken(lo, rest, ' ', 2);
        if (strlen(la) > 0 && strlen(lo) > 0) {
            sprintf(p_lat, "%.4f", atof(la)); sprintf(p_lon, "%.4f", atof(lo));
            strcpy(p_city, "manuell"); loc_set = 1; want_geoip = 0; fetched_at = -1; saveVars();
            responseCmnd("coords set");
        } else { responseCmnd("usage: WX coord <lat> <lon>"); }
        return;
    }
    if (cmd[i] == 'a') {                                      // auto (IP detect)
        loc_set = 0; want_geoip = 1; saveVars();
        responseCmnd("auto-detect queued");
        return;
    }
    fetched_at = -1;                                          // plain WX -> refetch
    responseCmnd("refetch queued");
}

int main() {
    addCommand("WX");
    // KWP/PR config: drop a template with the compiled defaults if none exists yet,
    // then read it. Edit /weather.cfg via the file manager to set your array.
    if (fileExists(CFG_FILE) == 0) {
        int hc = fileOpen(CFG_FILE, 1);
        if (hc >= 0) { sprintf(cfgbuf, "// weather.tc config - edit, then send WX to apply\n// Location by name (geocoded). Leave blank/commented to auto-detect by IP.\n// CITY=Erftstadt\n// PV peak power [kWp]\nKWP=%.2f\n// performance ratio (system losses)\nPR=%.2f\n// days to fetch/show forecast, clamped 4..16\nNDAYS=%d\n", kwp, pr, ndays); fileWrite(hc, cfgbuf, strlen(cfgbuf)); fileClose(hc); }
    }
    read_cfg();
    strcpy(jsdate, "var t=[];for(var i=0;i<dt.getNumberOfRows();i++){var d=dt.getValue(i,0);t.push({v:d,f:('0'+d.getDate()).slice(-2)+'.'+('0'+(d.getMonth()+1)).slice(-2)+'.'});}if(!o.hAxis)o.hAxis={};o.hAxis.ticks=t;");
    if (strlen(p_lat) < 2) {                  // first run — seed defaults
        strcpy(p_lat, DEF_LAT); strcpy(p_lon, DEF_LON); strcpy(p_city, DEF_CITY);
        loc_set = 0; saveVars();
    }
    // Location precedence: CITY= in the cfg (geocoded) > user-fixed (WX loc/coord) > IP auto-detect.
    strcpy(want_geocode, "");
    if (strlen(cfg_city) > 0) {
        want_geoip = 0;                                    // cfg pins the city -> no IP guess
        if (strcmp(cfg_city, p_city) != 0) { strcpy(want_geocode, cfg_city); }   // geocode if it changed
    } else {
        want_geoip = (loc_set == 0) ? 1 : 0;               // auto-detect on boot unless user-fixed
    }
    valid = 0;
    fetched_at = -1;
    addLog("weather ready - %s (%s,%s) loc_set=%d", p_city, p_lat, p_lon, loc_set);
    return 0;
}