/* * Parsing and presentation helpers for Home Assistant data. * * Deliberately free of ESP-IDF and network dependencies: this file is * compiled into the desktop simulator as well as the firmware, so the layout * work in sim/ exercises the same parser the device runs. Keep it that way -- * anything needing esp_* belongs in ha_client.c. */ #include #include #include #include #include "cJSON.h" #include "ha_data.h" /* Build the config table from the same list the JSON template is built from. */ #define HA_LIGHT_ENTRY_X(id, label, dimmable) { id, label, dimmable }, const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = { HA_LIGHT_LIST(HA_LIGHT_ENTRY_X) }; /* ------------------------------------------------------------------ values */ /* * A JSON null (or a missing key) means the sensor is unavailable and the * field stays invalid. Note cJSON_IsNumber() is false for null, so this * handles both cases without a separate null check. */ static void take_number(const cJSON *root, const char *key, ha_value_t *out) { const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); if (cJSON_IsNumber(item)) { out->value = (float)item->valuedouble; out->valid = true; } } static void take_string(const cJSON *root, const char *key, char *out, size_t len) { const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key); if (cJSON_IsString(item) && item->valuestring != NULL) { snprintf(out, len, "%s", item->valuestring); } } /* ------------------------------------------------------------------ lights */ static ha_light_state_t light_state_from_string(const char *s) { if (s == NULL) { return HA_LIGHT_UNAVAILABLE; } if (strcmp(s, "on") == 0) { return HA_LIGHT_ON; } if (strcmp(s, "off") == 0) { return HA_LIGHT_OFF; } /* "unavailable", "unknown", or anything unexpected. Treated the same: * the tile greys out rather than claiming the light is off. */ return HA_LIGHT_UNAVAILABLE; } static void parse_lights(const cJSON *root, ha_data_t *out) { const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights"); if (!cJSON_IsArray(lights)) { return; } /* * Index-aligned with HA_LIGHTS because the template iterates the same * X-macro list. If HA returns a different count, something is out of sync * between the template and the table -- take what lines up rather than * reading past either array. */ const int n = cJSON_GetArraySize(lights); const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT; for (int i = 0; i < count; i++) { const cJSON *entry = cJSON_GetArrayItem(lights, i); if (!cJSON_IsObject(entry)) { continue; } const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s"); out->lights[i].state = light_state_from_string( cJSON_IsString(state) ? state->valuestring : NULL); const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b"); out->lights[i].brightness = cJSON_IsNumber(bright) ? (int16_t)bright->valuedouble : -1; } } /* -------------------------------------------------------------- state JSON */ bool ha_parse_json(const char *json, ha_data_t *out) { if (json == NULL || out == NULL) { return false; } cJSON *root = cJSON_Parse(json); if (root == NULL) { return false; } /* * Preserve the forecast across the wipe. It arrives from a different, * slower request; zeroing the whole struct here would blank the forecast * strip on every sensor poll. */ ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS]; memcpy(saved_forecast, out->forecast, sizeof(saved_forecast)); const uint8_t saved_count = out->forecast_count; memset(out, 0, sizeof(*out)); memcpy(out->forecast, saved_forecast, sizeof(saved_forecast)); out->forecast_count = saved_count; take_number(root, "out_t", &out->outdoor_temp_f); take_number(root, "out_h", &out->outdoor_humidity); take_number(root, "in_t", &out->indoor_temp_f); take_number(root, "in_h", &out->indoor_humidity); take_number(root, "dew", &out->dew_point_f); take_number(root, "press", &out->pressure_inhg); take_number(root, "rain_d", &out->rain_today_in); take_number(root, "rain_r", &out->rain_rate_inhr); take_number(root, "wind", &out->wind_mph); take_number(root, "gust", &out->gust_mph); take_number(root, "wdir", &out->wind_dir_deg); take_string(root, "cond", out->condition, sizeof(out->condition)); take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise)); take_string(root, "sunset", out->sunset, sizeof(out->sunset)); take_string(root, "clock", out->clock, sizeof(out->clock)); take_string(root, "date", out->date, sizeof(out->date)); out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up")); parse_lights(root, out); cJSON_Delete(root); return true; } /* ----------------------------------------------------------- forecast JSON */ /* * Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence. * * The device has no clock, so the day name cannot come from localtime(). The * forecast timestamps are UTC; HA returns them at 19:00Z, which is midday * locally, so the UTC date and the local date agree. That would stop being * true for a timestamp near midnight UTC -- if HA ever changes the hour, this * needs the offset applied first. */ static void weekday_name(const char *iso, char *out, size_t len) { int y = 0, m = 0, d = 0; if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) { snprintf(out, len, "--"); return; } /* Zeller treats January and February as months 13 and 14 of the year * before. */ if (m < 3) { m += 12; y -= 1; } const int K = y % 100; const int J = y / 100; const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7; /* Zeller's h: 0 = Saturday. */ static const char *const names[7] = { "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri" }; snprintf(out, len, "%s", names[h]); } bool ha_parse_forecast_json(const char *json, ha_data_t *out) { if (json == NULL || out == NULL) { return false; } cJSON *root = cJSON_Parse(json); if (root == NULL) { return false; } bool ok = false; /* { "service_response": { "": { "forecast": [ ... ] } } } */ const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response"); const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL; const cJSON *list = cJSON_IsObject(entity) ? cJSON_GetObjectItemCaseSensitive(entity, "forecast") : NULL; if (cJSON_IsArray(list)) { const int n = cJSON_GetArraySize(list); const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS; memset(out->forecast, 0, sizeof(out->forecast)); for (int i = 0; i < count; i++) { const cJSON *day = cJSON_GetArrayItem(list, i); if (!cJSON_IsObject(day)) { continue; } ha_forecast_day_t *slot = &out->forecast[i]; const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime"); weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL, slot->day, sizeof(slot->day)); take_string(day, "condition", slot->condition, sizeof(slot->condition)); take_number(day, "temperature", &slot->high_f); take_number(day, "templow", &slot->low_f); } out->forecast_count = (uint8_t)count; ok = true; } cJSON_Delete(root); return ok; } /* ----------------------------------------------------------- presentation */ const char *ha_service_domain(const char *entity_id) { /* The patio plug lives in the switch domain; light.turn_on on a switch * entity is accepted by HA and silently does nothing. */ if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) { return "switch"; } return "light"; } typedef struct { const char *slug; const char *label; const char *shrt; } condition_name_t; /* met.no condition slugs as HA reports them. `shrt` is for the forecast * strip, where a column is about 150px wide. */ static const condition_name_t CONDITIONS[] = { { "clear-night", "Clear", "Clear" }, { "cloudy", "Cloudy", "Cloudy" }, { "exceptional", "Exceptional", "Severe" }, { "fog", "Fog", "Fog" }, { "hail", "Hail", "Hail" }, { "lightning", "Thunderstorms", "Storm" }, { "lightning-rainy", "Thunderstorms", "Storm" }, { "partlycloudy", "Partly Cloudy", "Partly" }, { "pouring", "Heavy Rain", "Heavy" }, { "rainy", "Rain", "Rain" }, { "snowy", "Snow", "Snow" }, { "snowy-rainy", "Wintry Mix", "Wintry" }, { "sunny", "Sunny", "Sunny" }, { "windy", "Windy", "Windy" }, { "windy-variant", "Windy", "Windy" }, }; static const condition_name_t *condition_lookup(const char *slug) { if (slug == NULL) { return NULL; } for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) { if (strcmp(CONDITIONS[i].slug, slug) == 0) { return &CONDITIONS[i]; } } return NULL; } const char *ha_condition_label(const char *slug) { const condition_name_t *c = condition_lookup(slug); /* Unrecognised slugs pass through rather than becoming "Unknown" -- if * met.no adds a condition, seeing the raw slug on screen says what to * add here. */ return (c != NULL) ? c->label : (slug != NULL ? slug : "--"); } const char *ha_condition_short(const char *slug) { const condition_name_t *c = condition_lookup(slug); return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--"); } const char *ha_wind_cardinal(float degrees) { static const char *const points[16] = { "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" }; if (!isfinite(degrees)) { return "--"; } /* fmodf twice: a negative bearing would otherwise index out of bounds. */ float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f); const int idx = (int)((d + 11.25f) / 22.5f) % 16; return points[idx]; }