323 lines
11 KiB
C
323 lines
11 KiB
C
/*
|
|
* 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 <math.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#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": { "<entity>": { "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];
|
|
}
|