[libgweather: 15/24] Add NWS (USA) backend




commit afe15c3507ec6bf89a8516528cedbd8586724973
Author: Ryan Hendrickson <ryan hendrickson alum mit edu>
Date:   Tue May 31 17:02:21 2022 -0400

    Add NWS (USA) backend

 .gitlab-ci.yml                   |    5 +
 libgweather/gweather-info.c      |    5 +
 libgweather/gweather-info.h      |    6 +-
 libgweather/gweather-private.h   |    3 +
 libgweather/meson.build          |    2 +
 libgweather/tools/test_weather.c |    2 +
 libgweather/weather-nws.c        | 1082 ++++++++++++++++++++++++++++++++++++++
 7 files changed, 1103 insertions(+), 2 deletions(-)
---
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 70ba4e6f..125cb7e2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -109,6 +109,7 @@ fedora-x86_64:
       git
       gobject-introspection-devel
       itstool
+      json-glib-devel
       libsoup-devel
       libxml2-devel
       ninja-build
@@ -197,6 +198,7 @@ static-scan:
       git
       gobject-introspection-devel
       itstool
+      json-glib-devel
       libsoup-devel
       libxml2-devel
       ninja-build
@@ -232,6 +234,7 @@ asan-build:
       git
       gobject-introspection-devel
       itstool
+      json-glib-devel
       libasan
       libsoup-devel
       libxml2-devel
@@ -268,6 +271,7 @@ coverage:
       git
       gobject-introspection-devel
       itstool
+      json-glib-devel
       lcov
       libsoup-devel
       libxml2-devel
@@ -310,6 +314,7 @@ reference:
       git
       gobject-introspection-devel
       itstool
+      json-glib-devel
       libsoup-devel
       libxml2-devel
       ninja-build
diff --git a/libgweather/gweather-info.c b/libgweather/gweather-info.c
index 694f609e..d33d3905 100644
--- a/libgweather/gweather-info.c
+++ b/libgweather/gweather-info.c
@@ -700,6 +700,11 @@ gweather_info_update (GWeatherInfo *info)
 
     ok = FALSE;
     /* Try national forecast services first */
+    if (info->providers & GWEATHER_PROVIDER_NWS)
+        ok = nws_start_open (info);
+    if (ok)
+        return;
+
     if (info->providers & GWEATHER_PROVIDER_IWIN)
         ok = iwin_start_open (info);
     if (ok)
diff --git a/libgweather/gweather-info.h b/libgweather/gweather-info.h
index ddf89d96..8102cc44 100644
--- a/libgweather/gweather-info.h
+++ b/libgweather/gweather-info.h
@@ -20,9 +20,10 @@ G_BEGIN_DECLS
  * GWeatherProvider:
  * @GWEATHER_PROVIDER_NONE: no provider, no weather information available
  * @GWEATHER_PROVIDER_METAR: METAR office, providing current conditions worldwide
- * @GWEATHER_PROVIDER_IWIN: US weather office, providing 7 days of forecast
+ * @GWEATHER_PROVIDER_IWIN: US weather office (old API), providing 7 days of forecast
  * @GWEATHER_PROVIDER_MET_NO: MET.no service, worldwide but requires attribution and a subscription to the 
[API users mailing-list](https://lists.met.no/mailman/listinfo/api-users).
  * @GWEATHER_PROVIDER_OWM: OpenWeatherMap, worldwide and possibly more reliable, but requires attribution 
and is limited in the number of queries
+ * @GWEATHER_PROVIDER_NWS: US weather office (new API), providing 7 days of hourly forecast (available since 
4.2)
  * @GWEATHER_PROVIDER_ALL: enable all available providers
  */
 typedef enum { /*< flags, underscore_name=gweather_provider >*/
@@ -31,7 +32,8 @@ typedef enum { /*< flags, underscore_name=gweather_provider >*/
     GWEATHER_PROVIDER_IWIN = 1 << 2,
     GWEATHER_PROVIDER_MET_NO = 1 << 3,
     GWEATHER_PROVIDER_OWM = 1 << 4,
-    GWEATHER_PROVIDER_ALL = (GWEATHER_PROVIDER_METAR | GWEATHER_PROVIDER_IWIN | GWEATHER_PROVIDER_MET_NO | 
GWEATHER_PROVIDER_OWM)
+    GWEATHER_PROVIDER_NWS = 1 << 5,
+    GWEATHER_PROVIDER_ALL = (GWEATHER_PROVIDER_METAR | GWEATHER_PROVIDER_IWIN | GWEATHER_PROVIDER_MET_NO | 
GWEATHER_PROVIDER_OWM | GWEATHER_PROVIDER_NWS)
 } GWeatherProvider;
 
 #define GWEATHER_TYPE_INFO (gweather_info_get_type ())
diff --git a/libgweather/gweather-private.h b/libgweather/gweather-private.h
index d2ba299c..ffe4464a 100644
--- a/libgweather/gweather-private.h
+++ b/libgweather/gweather-private.h
@@ -210,6 +210,9 @@ metno_start_open (GWeatherInfo *info);
 gboolean
 owm_start_open (GWeatherInfo *info);
 
+gboolean
+nws_start_open (GWeatherInfo *info);
+
 gboolean
 metar_parse (char *metar,
              GWeatherInfo *info);
diff --git a/libgweather/meson.build b/libgweather/meson.build
index d076c5fe..35675fc7 100644
--- a/libgweather/meson.build
+++ b/libgweather/meson.build
@@ -122,6 +122,7 @@ deps_libgweather = [
   libsoup_dep,
   dependency('libxml-2.0', version: libxml_req_version),
   geocode_glib_dep,
+  dependency('json-glib-1.0'),
 
   c_compiler.find_library('m', required: false),
 ]
@@ -154,6 +155,7 @@ gweather_priv_sources = [
   'weather-iwin.c',
   'weather-metno.c',
   'weather-owm.c',
+  'weather-nws.c',
   'weather-sun.c',
   'weather-moon.c',
 ]
diff --git a/libgweather/tools/test_weather.c b/libgweather/tools/test_weather.c
index 9518bc54..349ae7a0 100644
--- a/libgweather/tools/test_weather.c
+++ b/libgweather/tools/test_weather.c
@@ -116,6 +116,8 @@ set_providers (GWeatherInfo *info)
         ADD_PROVIDER_STR ("MET_NO");
     if (providers & GWEATHER_PROVIDER_OWM)
         ADD_PROVIDER_STR ("OWM");
+    if (providers & GWEATHER_PROVIDER_NWS)
+        ADD_PROVIDER_STR ("NWS");
     if (providers == GWEATHER_PROVIDER_NONE) {
         g_string_free (s, TRUE);
         g_warning ("No providers enabled, failing");
diff --git a/libgweather/weather-nws.c b/libgweather/weather-nws.c
new file mode 100644
index 00000000..2a7238b9
--- /dev/null
+++ b/libgweather/weather-nws.c
@@ -0,0 +1,1082 @@
+/* weather-nws.c - National Weather Service (USA)
+ *
+ * SPDX-FileCopyrightText: The GWeather authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "config.h"
+
+#include "gweather-private.h"
+
+#include <stdio.h>
+
+#include <glib.h>
+
+#include <json-glib/json-glib.h>
+
+#define JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS(in, name, tmpl, tmpl_suffix, ...)  \
+    if (!json_object_has_member (in, name)) {                                      \
+        g_warning ("Member `" #tmpl "` does not exist" #tmpl_suffix, __VA_ARGS__); \
+        return;                                                                    \
+    }
+
+#define JSON_OBJECT_GET_MEMBER_OR_RETURN(article, type, in, name, out, tmpl, tmpl_suffix, ...)  \
+    JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (in, name, tmpl, tmpl_suffix, __VA_ARGS__)          \
+    out = json_object_get_##type##_member (in, name);                                           \
+    if (out == NULL) {                                                                          \
+        g_warning ("Value at `" #tmpl "` is not " article " " #type #tmpl_suffix, __VA_ARGS__); \
+        return;                                                                                 \
+    }
+
+#define JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
+    JSON_OBJECT_GET_MEMBER_OR_RETURN ("an", array, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)
+#define JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
+    JSON_OBJECT_GET_MEMBER_OR_RETURN ("an", object, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)
+#define JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
+    JSON_OBJECT_GET_MEMBER_OR_RETURN ("a", string, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)
+
+#define JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN(in, index, out, tmpl, ...) \
+    out = json_array_get_object_element (in, index);                       \
+    if (out == NULL) {                                                     \
+        g_warning ("Value at `" #tmpl "` is not an object", __VA_ARGS__);  \
+        return;                                                            \
+    }
+
+typedef struct _TimePair
+{
+    time_t start;
+    time_t end;
+} TimePair;
+
+/* The documented values for properties.weather.values.*.value.*.weather in the
+ * responses generated by the /gridpoints endpoint.
+ *
+ * See https://www.weather.gov/documentation/services-web-api#/default/gridpoint
+ */
+typedef enum
+{
+    NWS_WEATHER_NULL,
+
+    NWS_WEATHER_BLOWING_DUST,
+    NWS_WEATHER_BLOWING_SAND,
+    NWS_WEATHER_BLOWING_SNOW,
+    NWS_WEATHER_DRIZZLE,
+    NWS_WEATHER_FOG,
+    NWS_WEATHER_FREEZING_DRIZZLE,
+    NWS_WEATHER_FREEZING_FOG,
+    NWS_WEATHER_FREEZING_RAIN,
+    NWS_WEATHER_FREEZING_SPRAY,
+    NWS_WEATHER_FROST,
+    NWS_WEATHER_HAIL,
+    NWS_WEATHER_HAZE,
+    NWS_WEATHER_ICE_CRYSTALS,
+    NWS_WEATHER_ICE_FOG,
+    NWS_WEATHER_RAIN,
+    NWS_WEATHER_RAIN_SHOWERS,
+    NWS_WEATHER_SLEET,
+    NWS_WEATHER_SMOKE,
+    NWS_WEATHER_SNOW,
+    NWS_WEATHER_SNOW_SHOWERS,
+    NWS_WEATHER_THUNDERSTORMS,
+    NWS_WEATHER_VOLCANIC_ASH,
+    NWS_WEATHER_WATER_SPOUTS,
+
+    NWS_WEATHER_UNRECOGNIZED
+} NwsWeather;
+
+static NwsWeather
+parse_nws_weather (const gchar *str)
+{
+    if (str == NULL)
+        return NWS_WEATHER_NULL;
+
+    switch (str[0]) {
+        case 'b':
+            if (strncmp (str + 1, "lowing_", 7))
+                break;
+            switch (str[8]) {
+                case 'd':
+                    if (strcmp (str + 9, "ust"))
+                        break;
+                    return NWS_WEATHER_BLOWING_DUST;
+                case 's':
+                    switch (str[9]) {
+                        case 'a':
+                            if (strcmp (str + 10, "nd"))
+                                break;
+                            return NWS_WEATHER_BLOWING_SAND;
+                        case 'n':
+                            if (strcmp (str + 10, "ow"))
+                                break;
+                            return NWS_WEATHER_BLOWING_SNOW;
+                    }
+                    break;
+            }
+            break;
+        case 'd':
+            if (strcmp (str + 1, "rizzle"))
+                break;
+            return NWS_WEATHER_DRIZZLE;
+        case 'f':
+            switch (str[1]) {
+                case 'o':
+                    if (strcmp (str + 2, "g"))
+                        break;
+                    return NWS_WEATHER_FOG;
+                case 'r':
+                    switch (str[2]) {
+                        case 'e':
+                            if (strncmp (str + 3, "ezing_", 6))
+                                break;
+                            switch (str[9]) {
+                                case 'd':
+                                    if (strcmp (str + 10, "rizzle"))
+                                        break;
+                                    return NWS_WEATHER_FREEZING_DRIZZLE;
+                                case 'f':
+                                    if (strcmp (str + 10, "og"))
+                                        break;
+                                    return NWS_WEATHER_FREEZING_FOG;
+                                case 'r':
+                                    if (strcmp (str + 10, "ain"))
+                                        break;
+                                    return NWS_WEATHER_FREEZING_RAIN;
+                                case 's':
+                                    if (strcmp (str + 10, "pray"))
+                                        break;
+                                    return NWS_WEATHER_FREEZING_SPRAY;
+                            }
+                            break;
+                        case 'o':
+                            if (strcmp (str + 3, "st"))
+                                break;
+                            return NWS_WEATHER_FROST;
+                    }
+                    break;
+            }
+            break;
+        case 'h':
+            if (str[1] != 'a')
+                break;
+            switch (str[2]) {
+                case 'i':
+                    if (strcmp (str + 3, "l"))
+                        break;
+                    return NWS_WEATHER_HAIL;
+                case 'z':
+                    if (strcmp (str + 3, "e"))
+                        break;
+                    return NWS_WEATHER_HAZE;
+            }
+            break;
+        case 'i':
+            if (strncmp (str + 1, "ce_", 3))
+                break;
+            switch (str[4]) {
+                case 'c':
+                    if (strcmp (str + 5, "rystals"))
+                        break;
+                    return NWS_WEATHER_ICE_CRYSTALS;
+                case 'f':
+                    if (strcmp (str + 5, "og"))
+                        break;
+                    return NWS_WEATHER_ICE_FOG;
+            }
+            break;
+        case 'r':
+            if (strncmp (str + 1, "ain", 3))
+                break;
+            switch (str[4]) {
+                case '\0':
+                    return NWS_WEATHER_RAIN;
+                case '_':
+                    if (strcmp (str + 5, "showers"))
+                        break;
+                    return NWS_WEATHER_RAIN_SHOWERS;
+            }
+            break;
+        case 's':
+            switch (str[1]) {
+                case 'l':
+                    if (strcmp (str + 2, "eet"))
+                        break;
+                    return NWS_WEATHER_SLEET;
+                case 'm':
+                    if (strcmp (str + 2, "oke"))
+                        break;
+                    return NWS_WEATHER_SMOKE;
+                case 'n':
+                    if (strncmp (str + 2, "ow", 2))
+                        break;
+                    switch (str[4]) {
+                        case '\0':
+                            return NWS_WEATHER_SNOW;
+                        case '_':
+                            if (strcmp (str + 5, "showers"))
+                                break;
+                            return NWS_WEATHER_SNOW_SHOWERS;
+                    }
+                    break;
+            }
+            break;
+        case 't':
+            if (strcmp (str + 1, "hunderstorms"))
+                break;
+            return NWS_WEATHER_THUNDERSTORMS;
+        case 'v':
+            if (strcmp (str + 1, "olcanic_ash"))
+                break;
+            return NWS_WEATHER_VOLCANIC_ASH;
+        case 'w':
+            if (strcmp (str + 1, "ater_spouts"))
+                break;
+            return NWS_WEATHER_WATER_SPOUTS;
+    }
+    return NWS_WEATHER_UNRECOGNIZED;
+}
+
+/**
+ * times_from_iso8601_interval:
+ *
+ * A very incomplete parser for the ISO 8601 format for time intervals like
+ * 2022-01-13T18:00:00Z/PT6H. Doesn't support a lot of the more advanced
+ * features of the standard. Replacing this with a function in some other
+ * library would be a great idea.
+ *
+ * Return value: a pair of time_t values representing the start and the end of
+ * the interval.
+ **/
+static TimePair
+times_from_iso8601_interval (const gchar *str)
+{
+    TimePair ret = { 0, 0 };
+    const gchar *sep;
+    gchar *date_part;
+    g_autoptr (GDateTime) dt_start = NULL;
+    g_autoptr (GDateTime) dt_end = NULL;
+
+    sep = strchr (str, '/');
+    if (sep == NULL)
+        return ret;
+
+    date_part = g_strndup (str, sep - str);
+    dt_start = g_date_time_new_from_iso8601 (date_part, NULL);
+    g_free (date_part);
+    ret.start = g_date_time_to_unix (dt_start);
+
+    if (*(sep + 1) == 'P') {
+        gint years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0;
+        gboolean in_date = TRUE;
+        for (const gchar *ptr = sep + 2; *ptr != '\0';) {
+            if (*ptr == 'T') {
+                in_date = FALSE;
+                ptr++;
+            } else {
+                int num, advanced;
+                gchar field;
+                sscanf (ptr, "%d%c%n", &num, &field, &advanced);
+                if (in_date) {
+                    switch (field) {
+                        case 'Y':
+                            years = num;
+                            break;
+                        case 'M':
+                            months = num;
+                            break;
+                        case 'D':
+                            days = num;
+                            break;
+                        default:
+                            ret.end = ret.start;
+                            return ret;
+                    }
+                } else {
+                    switch (field) {
+                        case 'H':
+                            hours = num;
+                            break;
+                        case 'M':
+                            minutes = num;
+                            break;
+                        case 'S':
+                            seconds = num;
+                            break;
+                        default:
+                            ret.end = ret.start;
+                            return ret;
+                    }
+                }
+                ptr += advanced;
+            }
+        }
+        dt_end = g_date_time_add_full (dt_start, years, months, days, hours, minutes, (gdouble) seconds);
+    } else {
+        dt_end = g_date_time_new_from_iso8601 (sep + 1, NULL);
+    }
+
+    ret.end = g_date_time_to_unix (dt_end);
+    return ret;
+}
+
+static gboolean
+json_array_contains_string (JsonArray *arr, const gchar *str)
+{
+    guint len;
+    const gchar *str2;
+
+    len = json_array_get_length (arr);
+    for (guint i = 0; i < len; i++) {
+        str2 = json_array_get_string_element (arr, i);
+        if (str2 != NULL && strcmp (str, str2) == 0) {
+            return TRUE;
+        }
+    }
+
+    return FALSE;
+}
+
+static SoupMessage *
+nws_new_request (GWeatherInfo *info, const gchar *url)
+{
+    SoupMessage *message;
+    SoupMessageHeaders *headers;
+
+    message = soup_message_new ("GET", url);
+    _gweather_info_begin_request (info, message);
+#if SOUP_CHECK_VERSION(2, 99, 2)
+    headers = soup_message_get_request_headers (message);
+#else
+    headers = message->request_headers;
+#endif
+    soup_message_headers_append (headers, "Accept", "application/geo+json");
+
+    return message;
+}
+
+typedef void (*ValueReader) (GWeatherInfo *, JsonNode *);
+
+static void
+read_interval_values (GSList *forecast_list,
+                      JsonObject *obj,
+                      gchar *property_name,
+                      ValueReader (*select_read_value) (const gchar *),
+                      void (*copy_value) (GWeatherInfo *, GWeatherInfo *))
+{
+    JsonObject *prop;
+    JsonArray *arr;
+    const gchar *uom;
+    const gchar *valid_time;
+    ValueReader read_value;
+    guint len;
+    JsonObject *datum;
+    TimePair range;
+    guint i = 0;
+    GWeatherInfo *prev = NULL;
+    GWeatherInfo *info;
+
+    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, property_name, prop, "%s", "", property_name)
+
+    JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN (prop, "values", arr, "%s.values", "", property_name)
+
+    uom = NULL;
+    if (json_object_has_member (prop, "uom")) {
+        uom = json_object_get_string_member (prop, "uom");
+    }
+
+    read_value = (*select_read_value) (uom);
+    len = json_array_get_length (arr);
+    if (len > 0) {
+        JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, datum, "%s.values[%d]", property_name, i)
+
+        JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (datum, "validTime", valid_time, "%s.values[%d].validTime", 
"", property_name, i)
+
+        range = times_from_iso8601_interval (valid_time);
+        for (GSList *slist = forecast_list; slist != NULL; slist = slist->next) {
+            info = slist->data;
+            while (info->current_time >= range.end) {
+                i++;
+                if (i >= len) {
+                    return;
+                }
+                JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, datum, "%s.values[%d]", property_name, i)
+
+                JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (datum, "validTime", valid_time, 
"%s.values[%d].validTime", "", property_name, i)
+
+                range = times_from_iso8601_interval (valid_time);
+                prev = NULL;
+            }
+            if (info->current_time >= range.start) {
+                if (prev == NULL) {
+                    JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (datum, "value", "%s.values[%d].value", "", 
property_name, i)
+                    (*read_value) (info, json_object_get_member (datum, "value"));
+                } else {
+                    (*copy_value) (info, prev);
+                }
+                prev = info;
+            }
+        }
+    }
+}
+
+static void
+read_temperature_f (GWeatherInfo *info, JsonNode *node)
+{
+    info->temp = json_node_get_double (node);
+}
+
+static void
+read_temperature_c (GWeatherInfo *info, JsonNode *node)
+{
+    info->temp = TEMP_C_TO_F (json_node_get_double (node));
+}
+
+static ValueReader
+select_read_temperature (const gchar *uom)
+{
+    if (uom != NULL && strcmp (uom, "wmoUnit:degC") == 0) {
+        return read_temperature_c;
+    }
+    return read_temperature_f;
+}
+
+static void
+copy_temperature (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->temp = prev->temp;
+}
+
+static void
+read_dew_f (GWeatherInfo *info, JsonNode *node)
+{
+    info->dew = json_node_get_double (node);
+}
+
+static void
+read_dew_c (GWeatherInfo *info, JsonNode *node)
+{
+    info->dew = TEMP_C_TO_F (json_node_get_double (node));
+}
+
+static ValueReader
+select_read_dew (const gchar *uom)
+{
+    if (uom != NULL && strcmp (uom, "wmoUnit:degC") == 0) {
+        return read_dew_c;
+    }
+    return read_dew_f;
+}
+
+static void
+copy_dew (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->dew = prev->dew;
+}
+
+static void
+read_weather (GWeatherInfo *info, JsonNode *node)
+{
+    GWeatherConditions conditions = { FALSE, GWEATHER_PHENOMENON_NONE, GWEATHER_QUALIFIER_NONE };
+    JsonArray *arr;
+    guint len;
+    JsonObject *obj;
+    NwsWeather weather;
+    const gchar *coverage;
+    const gchar *intensity;
+    GWeatherConditionQualifier intensity_qualifier = GWEATHER_QUALIFIER_NONE;
+    JsonArray *attributes;
+
+    if (!JSON_NODE_HOLDS_ARRAY (node)) {
+        g_warning ("Value is not an array");
+        return;
+    }
+    arr = json_node_get_array (node);
+    len = json_array_get_length (arr);
+
+    // Deeply imperfect, but: for the first element in the array that produces
+    // a coherent GWeatherConditions, use it. If there's another element in the
+    // array with broader coverage, higher intensity, or is just more dramatic
+    // (tornadoes!), too bad.
+    //
+    // A smarter approach might take such things into account, and attempt to
+    // report on the most overall significant weather phenomenon based on some
+    // prioritization of entries in this array, or perhaps merge them in some
+    // way.
+    for (guint i = 0; i < len; i++) {
+        JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, obj, "[%d]", i)
+
+        // Can be NULL; parse_nws_weather maps NULL to NWS_WEATHER_NULL
+        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "weather", "[%d].weather", "", i)
+        weather = parse_nws_weather (json_object_get_string_member (obj, "weather"));
+
+        // NULL or one of: areas, brief, chance, definite, few, frequent,
+        // intermittent, isolated, likely, numerous, occasional, patchy,
+        // periods, scattered, slight_chance, widespread
+        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "coverage", "[%d].coverage", "", i)
+        coverage = json_object_get_string_member (obj, "coverage");
+        if (coverage != NULL) {
+            // We don't want to use weather conditions if they come with a
+            // coverage keyword that indicates the conditions are less likely
+            // than not to come to pass. The values chosen to be excluded are
+            // taken from the table here:
+            // https://vlab.noaa.gov/web/mdl/weather-type-definitions
+            if (strcmp (coverage, "slight_chance") == 0 ||
+                strcmp (coverage, "chance") == 0 ||
+                strcmp (coverage, "isolated") == 0 ||
+                strcmp (coverage, "scattered") == 0) {
+                continue;
+            }
+        }
+
+        // NULL or one of: very_light, light, moderate, heavy
+        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "intensity", "[%d].intensity", "", i)
+        intensity = json_object_get_string_member (obj, "intensity");
+        if (intensity != NULL) {
+            if (strcmp (intensity, "very_light") == 0 || strcmp (intensity, "light") == 0) {
+                intensity_qualifier = GWEATHER_QUALIFIER_LIGHT;
+            } else if (strcmp (intensity, "moderate") == 0) {
+                intensity_qualifier = GWEATHER_QUALIFIER_MODERATE;
+            } else if (strcmp (intensity, "heavy") == 0) {
+                intensity_qualifier = GWEATHER_QUALIFIER_HEAVY;
+            }
+        }
+
+        // Array of damaging_wind, dry_thunderstorms, flooding, gusty_wind, heavy_rain, large_hail, 
small_hail, tornadoes
+        JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN (obj, "attributes", attributes, "[%d].attributes", "", i)
+
+        switch (weather) {
+            case NWS_WEATHER_FREEZING_DRIZZLE:
+            case NWS_WEATHER_DRIZZLE:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_DRIZZLE;
+                if (weather == NWS_WEATHER_FREEZING_DRIZZLE) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
+                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
+                    conditions.qualifier = intensity_qualifier;
+                }
+                break;
+            case NWS_WEATHER_FREEZING_RAIN:
+            case NWS_WEATHER_RAIN:
+            case NWS_WEATHER_RAIN_SHOWERS:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_RAIN;
+                if (weather == NWS_WEATHER_FREEZING_RAIN) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
+                } else if (weather == NWS_WEATHER_RAIN_SHOWERS) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_SHOWERS;
+                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
+                    conditions.qualifier = intensity_qualifier;
+                }
+                break;
+            case NWS_WEATHER_BLOWING_SNOW:
+            case NWS_WEATHER_SNOW:
+            case NWS_WEATHER_SNOW_SHOWERS:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_SNOW;
+                if (weather == NWS_WEATHER_BLOWING_SNOW) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
+                } else if (weather == NWS_WEATHER_SNOW_SHOWERS) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_SHOWERS;
+                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
+                    conditions.qualifier = intensity_qualifier;
+                }
+                break;
+            case NWS_WEATHER_ICE_CRYSTALS:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_ICE_CRYSTALS;
+                break;
+            case NWS_WEATHER_SLEET:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_ICE_PELLETS;
+                if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
+                    conditions.qualifier = intensity_qualifier;
+                }
+                break;
+            case NWS_WEATHER_HAIL:
+                conditions.significant = TRUE;
+                if (json_array_contains_string (attributes, "small_hail")) {
+                    conditions.phenomenon = GWEATHER_PHENOMENON_SMALL_HAIL;
+                } else {
+                    conditions.phenomenon = GWEATHER_PHENOMENON_HAIL;
+                }
+                break;
+            case NWS_WEATHER_FOG:
+            case NWS_WEATHER_FREEZING_FOG:
+            case NWS_WEATHER_ICE_FOG:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_FOG;
+                if (weather != NWS_WEATHER_FOG) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
+                } else if (strcmp (coverage, "areas") == 0) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_PARTIAL;
+                } else if (strcmp (coverage, "patchy") == 0) {
+                    conditions.qualifier = GWEATHER_QUALIFIER_PATCHES;
+                }
+                break;
+            case NWS_WEATHER_SMOKE:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_SMOKE;
+                break;
+            case NWS_WEATHER_VOLCANIC_ASH:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_VOLCANIC_ASH;
+                break;
+            case NWS_WEATHER_BLOWING_SAND:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_SAND;
+                conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
+                break;
+            case NWS_WEATHER_HAZE:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_HAZE;
+                break;
+            case NWS_WEATHER_FREEZING_SPRAY:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_SPRAY;
+                conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
+                break;
+            case NWS_WEATHER_BLOWING_DUST:
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_DUST;
+                conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
+                break;
+            case NWS_WEATHER_THUNDERSTORMS:
+                conditions.significant = TRUE;
+                conditions.qualifier = GWEATHER_QUALIFIER_THUNDERSTORM;
+                break;
+            case NWS_WEATHER_NULL:
+            case NWS_WEATHER_FROST:
+            case NWS_WEATHER_WATER_SPOUTS:
+            case NWS_WEATHER_UNRECOGNIZED:
+                break;
+        }
+        if (conditions.phenomenon == GWEATHER_PHENOMENON_NONE) {
+            if (json_array_contains_string (attributes, "tornadoes")) {
+                conditions.significant = TRUE;
+                conditions.phenomenon = GWEATHER_PHENOMENON_TORNADO;
+            }
+        }
+
+        if (conditions.significant) {
+            break;
+        }
+    }
+    info->cond = conditions;
+}
+
+static ValueReader
+select_read_weather (const gchar *uom)
+{
+    return read_weather;
+}
+
+static void
+copy_weather (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->cond = prev->cond;
+}
+
+static void
+read_sky (GWeatherInfo *info, JsonNode *node)
+{
+    gdouble pct = json_node_get_double (node);
+    if (pct >= 0 && pct <= 100) {
+        if (pct < 12.5) {
+            info->sky = GWEATHER_SKY_CLEAR;
+        } else if (pct < 37.5) {
+            info->sky = GWEATHER_SKY_BROKEN;
+        } else if (pct < 62.5) {
+            info->sky = GWEATHER_SKY_SCATTERED;
+        } else if (pct < 87.5) {
+            info->sky = GWEATHER_SKY_FEW;
+        } else {
+            info->sky = GWEATHER_SKY_OVERCAST;
+        }
+    }
+}
+
+static ValueReader
+select_read_sky (const gchar *uom)
+{
+    return read_sky;
+}
+
+static void
+copy_sky (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->sky = prev->sky;
+}
+
+static void
+read_winddir (GWeatherInfo *info, JsonNode *node)
+{
+    gdouble wind = json_node_get_double (node);
+    if (wind >= 0 && wind < 360) {
+        if (wind >= 348.75) {
+            info->wind = GWEATHER_WIND_N;
+        } else {
+            info->wind = GWEATHER_WIND_N + (int) ((wind + 11.25) / 22.5);
+        }
+    }
+}
+
+static ValueReader
+select_read_winddir (const gchar *uom)
+{
+    return read_winddir;
+}
+
+static void
+copy_winddir (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->wind = prev->wind;
+}
+
+static void
+read_windspeed (GWeatherInfo *info, JsonNode *node)
+{
+    gdouble windspeed_kph = json_node_get_double (node);
+    info->windspeed = WINDSPEED_MS_TO_KNOTS (windspeed_kph / 3.6);
+}
+
+static ValueReader
+select_read_windspeed (const gchar *uom)
+{
+    return read_windspeed;
+}
+
+static void
+copy_windspeed (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->windspeed = prev->windspeed;
+}
+
+static void
+read_humidity (GWeatherInfo *info, JsonNode *node)
+{
+    info->humidity = json_node_get_double (node);
+    info->hasHumidity = TRUE;
+}
+
+static ValueReader
+select_read_humidity (const gchar *uom)
+{
+    return read_humidity;
+}
+
+static void
+copy_humidity (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->humidity = prev->humidity;
+    info->hasHumidity = prev->hasHumidity;
+}
+
+static void
+read_visibility_m (GWeatherInfo *info, JsonNode *node)
+{
+    info->visibility = json_node_get_double (node) / VISIBILITY_SM_TO_M (1.);
+}
+
+static void
+read_visibility_sm (GWeatherInfo *info, JsonNode *node)
+{
+    info->visibility = json_node_get_double (node);
+}
+
+static ValueReader
+select_read_visibility (const gchar *uom)
+{
+    if (strcmp (uom, "wmoUnit:m") == 0) {
+        return read_visibility_m;
+    }
+    return read_visibility_sm;
+}
+
+static void
+copy_visibility (GWeatherInfo *info, GWeatherInfo *prev)
+{
+    info->visibility = prev->visibility;
+}
+
+static void
+nws_finish_forecast_common (GWeatherInfo *info,
+                            const char *content,
+                            gsize body_size)
+{
+    WeatherLocation *loc;
+    JsonNode *root;
+    JsonObject *obj;
+    const char *valid_times;
+    TimePair range;
+    GWeatherInfo *info2;
+    guint num_forecasts = 0;
+
+    loc = &info->location;
+    g_debug ("nws gridpoint data for %lf, %lf", loc->latitude, loc->longitude);
+    g_debug ("%s", content);
+
+    g_autoptr (JsonParser) parser = json_parser_new ();
+    g_autoptr (GError) error = NULL;
+    if (!json_parser_load_from_data (parser, content, body_size, &error)) {
+        g_warning ("Failed to parse response from weather.gov: %s", error->message);
+        return;
+    }
+
+    root = json_parser_get_root (parser);
+    if (!JSON_NODE_HOLDS_OBJECT (root)) {
+        g_warning ("Response from weather.gov is not an object: %s", content);
+        return;
+    }
+    obj = json_node_get_object (root);
+
+    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, "properties", obj, "properties", ": %s", content)
+
+    /* The gridpoints API uses an encoding in which each weather variable
+     * is an array of values tagged with time *intervals*. So far, all of
+     * the intervals I've seen have been multiples of an hour, although
+     * this isn't guaranteed by the API specification. The intervals are
+     * *not* uniform, and different weather variables are often broken into
+     * different time intervals (based, perhaps, on whether there is an
+     * actual change forecasted from one hour to the next).
+     *
+     * So, to decode this, we create one forecast info object for every
+     * hour in the overall response's validTimes range, and then repeatedly
+     * iterate over the list of infos, populating the info object one
+     * variable at a time.
+     */
+
+    JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (obj, "validTimes", valid_times, "properties.validTimes", ": 
%s", content)
+
+    range = times_from_iso8601_interval (valid_times);
+    // POSIX time doesn't care about leap seconds, neither does GLib, so
+    // whatever, an hour is always 3600 seconds.
+    for (time_t t = range.start; t < range.end; t += 3600) {
+        info2 = _gweather_info_new_clone (info);
+        info2->valid = TRUE;
+        info2->current_time = info2->update = t;
+        info->forecast_list = g_slist_prepend (info->forecast_list, info2);
+        num_forecasts++;
+    }
+    info->forecast_list = g_slist_reverse (info->forecast_list);
+
+    read_interval_values (info->forecast_list, obj, "weather", select_read_weather, copy_weather);
+    read_interval_values (info->forecast_list, obj, "temperature", select_read_temperature, 
copy_temperature);
+    read_interval_values (info->forecast_list, obj, "dewpoint", select_read_dew, copy_dew);
+    read_interval_values (info->forecast_list, obj, "skyCover", select_read_sky, copy_sky);
+    read_interval_values (info->forecast_list, obj, "windDirection", select_read_winddir, copy_winddir);
+    read_interval_values (info->forecast_list, obj, "windSpeed", select_read_windspeed, copy_windspeed);
+    read_interval_values (info->forecast_list, obj, "relativeHumidity", select_read_humidity, copy_humidity);
+    read_interval_values (info->forecast_list, obj, "visibility", select_read_visibility, copy_visibility);
+
+    g_debug ("nws generated %d forecast infos", num_forecasts);
+    if (!info->valid)
+        info->valid = (num_forecasts > 0);
+}
+
+#if SOUP_CHECK_VERSION(2, 99, 2)
+static void
+nws_finish_forecast (GObject *source,
+                     GAsyncResult *result,
+                     gpointer data)
+{
+    GWeatherInfo *info;
+    SoupSession *session = SOUP_SESSION (source);
+    SoupMessage *msg = soup_session_get_async_result_message (session, result);
+    GBytes *body;
+    GError *error = NULL;
+    const char *content;
+    gsize body_size;
+
+    body = soup_session_send_and_read_finish (session, result, &error);
+
+    if (!body) {
+        if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
+            g_debug ("Failed to get weather.gov gridpoint data: %s", error->message);
+            return;
+        }
+        g_message ("Failed to get weather.gov gridpoint data: %s", error->message);
+        g_clear_error (&error);
+        _gweather_info_request_done (data, msg);
+        return;
+    } else if (!SOUP_STATUS_IS_SUCCESSFUL (soup_message_get_status (msg))) {
+        g_message ("Failed to get weather.gov gridpoint data: [status: %d] %s",
+                   soup_message_get_status (msg),
+                   soup_message_get_reason_phrase (msg));
+        _gweather_info_request_done (data, msg);
+        return;
+    }
+
+    content = g_bytes_get_data (body, &body_size);
+
+    info = data;
+
+    nws_finish_forecast_common (info, content, body_size);
+
+    g_bytes_unref (body);
+    _gweather_info_request_done (info, msg);
+}
+#else
+static void
+nws_finish_forecast (SoupSession *session,
+                     SoupMessage *msg,
+                     gpointer user_data)
+{
+    GWeatherInfo *info;
+
+    info = user_data;
+
+    if (!SOUP_STATUS_IS_SUCCESSFUL (msg->status_code)) {
+        if (msg->status_code == SOUP_STATUS_CANCELLED) {
+            g_debug ("Failed to get weather.gov gridpoint data: %s",
+                     msg->reason_phrase);
+            return;
+        }
+        g_debug ("Failed to get weather.gov gridpoint data: [status: %d]: %s",
+                 msg->status_code,
+                 msg->reason_phrase);
+    } else {
+        nws_finish_forecast_common (info, msg->response_body->data, msg->response_body->length);
+    }
+
+    _gweather_info_request_done (info, msg);
+}
+#endif
+
+static void
+nws_finish_new_common (GWeatherInfo *info,
+                       const char *content,
+                       gsize body_size)
+{
+    WeatherLocation *loc;
+    JsonNode *root;
+    JsonObject *obj;
+    const gchar *url;
+    SoupMessage *msg;
+
+    loc = &info->location;
+    g_debug ("nws data for %lf, %lf", loc->latitude, loc->longitude);
+    g_debug ("%s", content);
+
+    g_autoptr (JsonParser) parser = json_parser_new ();
+    g_autoptr (GError) error = NULL;
+    if (!json_parser_load_from_data (parser, content, body_size, &error)) {
+        g_warning ("Failed to parse response from weather.gov: %s", error->message);
+        return;
+    }
+
+    root = json_parser_get_root (parser);
+    if (!JSON_NODE_HOLDS_OBJECT (root)) {
+        g_warning ("Response from weather.gov is not an object: %s", content);
+        return;
+    }
+    obj = json_node_get_object (root);
+
+    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, "properties", obj, "properties", ": %s", content)
+
+    // The endpoint at forecastGridData offers a superset of the
+    // information available from the other endpoints, albeit in a more
+    // complex format. Perhaps at some future date, the friendlier
+    // forecastHourly endpoint will be sufficient.
+    JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (obj, "forecastGridData", url, "properties.forecastGridData", ": 
%s", content)
+
+    msg = nws_new_request (info, url);
+    _gweather_info_queue_request (info, msg, nws_finish_forecast);
+}
+
+#if SOUP_CHECK_VERSION(2, 99, 2)
+static void
+nws_finish_new (GObject *source,
+                GAsyncResult *result,
+                gpointer data)
+{
+    GWeatherInfo *info;
+    SoupSession *session = SOUP_SESSION (source);
+    SoupMessage *msg = soup_session_get_async_result_message (session, result);
+    GBytes *body;
+    GError *error = NULL;
+    const char *content;
+    gsize body_size;
+
+    body = soup_session_send_and_read_finish (session, result, &error);
+
+    if (!body) {
+        if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
+            g_debug ("Failed to get weather.gov point data: %s", error->message);
+            return;
+        }
+        g_message ("Failed to get weather.gov point data: %s", error->message);
+        g_clear_error (&error);
+        _gweather_info_request_done (data, msg);
+        return;
+    } else if (!SOUP_STATUS_IS_SUCCESSFUL (soup_message_get_status (msg))) {
+        g_message ("Failed to get weather.gov point data: [status: %d] %s",
+                   soup_message_get_status (msg),
+                   soup_message_get_reason_phrase (msg));
+        _gweather_info_request_done (data, msg);
+        return;
+    }
+
+    content = g_bytes_get_data (body, &body_size);
+
+    info = data;
+
+    nws_finish_new_common (info, content, body_size);
+
+    g_bytes_unref (body);
+    _gweather_info_request_done (info, msg);
+}
+#else
+static void
+nws_finish_new (SoupSession *session,
+                SoupMessage *msg,
+                gpointer user_data)
+{
+    GWeatherInfo *info;
+
+    info = user_data;
+
+    if (!SOUP_STATUS_IS_SUCCESSFUL (msg->status_code)) {
+        if (msg->status_code == SOUP_STATUS_CANCELLED) {
+            g_debug ("Failed to get weather.gov point data: %s",
+                     msg->reason_phrase);
+            return;
+        }
+        g_debug ("Failed to get weather.gov point data: [status: %d]: %s",
+                 msg->status_code,
+                 msg->reason_phrase);
+    } else {
+        nws_finish_new_common (info, msg->response_body->data, msg->response_body->length);
+    }
+
+    _gweather_info_request_done (info, msg);
+}
+#endif
+
+gboolean
+nws_start_open (GWeatherInfo *info)
+{
+    gchar *url;
+    SoupMessage *message;
+    WeatherLocation *loc;
+    g_autofree gchar *latstr = NULL;
+    g_autofree gchar *lonstr = NULL;
+
+    loc = &info->location;
+
+    if (!loc->latlon_valid)
+        return FALSE;
+
+    /* see the description here: https://www.weather.gov/documentation/services-web-api */
+
+    latstr = _radians_to_degrees_str (loc->latitude);
+    lonstr = _radians_to_degrees_str (loc->longitude);
+
+    url = g_strdup_printf ("https://api.weather.gov/points/%s%%2C%s";, latstr, lonstr);
+    g_debug ("nws_start_open, requesting: %s", url);
+
+    message = nws_new_request (info, url);
+    _gweather_info_queue_request (info, message, nws_finish_new);
+
+    g_free (url);
+
+    return TRUE;
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]