aboutsummaryrefslogtreecommitdiff
path: root/internal/imgw/imgw.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/imgw/imgw.go')
-rw-r--r--internal/imgw/imgw.go156
1 files changed, 156 insertions, 0 deletions
diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go
new file mode 100644
index 0000000..48ffe19
--- /dev/null
+++ b/internal/imgw/imgw.go
@@ -0,0 +1,156 @@
+// Package imgw fetches official Polish meteorological warnings and resolves a
+// point to the powiat code those warnings are tagged with.
+//
+// Both services are public and need no key.
+package imgw
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// Endpoints are variables so tests can serve recorded fixtures locally.
+var (
+ warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo"
+ gugikURL = "https://services.gugik.gov.pl/uug/"
+)
+
+// now is the clock, replaceable in tests: whether a warning has expired depends
+// on it.
+var now = time.Now
+
+// Timeout bounds every request.
+var Timeout = 15 * time.Second
+
+// Status is the outcome of resolving a point to a powiat.
+type Status int
+
+const (
+ // StatusOK means the point resolved to a powiat code.
+ StatusOK Status = iota
+ // StatusOutside means GUGiK answered but knows no address there. It covers
+ // Poland only, so the point is abroad.
+ StatusOutside
+ // StatusError means the service could not be asked.
+ //
+ // This must never be conflated with StatusOutside: one means "no warnings
+ // apply here", the other "I do not know whether any apply".
+ StatusError
+)
+
+// Warning is one IMGW warning in force.
+type Warning struct {
+ Event string `json:"nazwa_zdarzenia"`
+ Level string `json:"stopien"`
+ Probability string `json:"prawdopodobienstwo"`
+ From string `json:"obowiazuje_od"`
+ To string `json:"obowiazuje_do"`
+ Text string `json:"tresc"`
+ Teryt []any `json:"teryt"`
+}
+
+func fetch(rawURL string, params url.Values, into any) error {
+ host := ""
+ if u, err := url.Parse(rawURL); err == nil {
+ host = u.Host
+ }
+ full := rawURL
+ if len(params) > 0 {
+ full += "?" + params.Encode()
+ }
+ req, err := http.NewRequest("GET", full, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "prognosis/1.0")
+ resp, err := (&http.Client{Timeout: Timeout}).Do(req)
+ if err != nil {
+ return fmt.Errorf("cannot reach %s: %w", host, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, into)
+}
+
+type gugikResponse struct {
+ Results map[string]struct {
+ Teryt string `json:"teryt"`
+ } `json:"results"`
+}
+
+// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK.
+//
+// IMGW tags every warning with the powiat codes it covers, so this is what
+// makes "warnings for my area" mean this area rather than the whole country.
+func Powiat(lat, lon float64) (string, Status, error) {
+ var r gugikResponse
+ err := fetch(gugikURL, url.Values{
+ "request": {"GetAddressReverse"},
+ "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)},
+ "srid": {"4326"},
+ // The default 100 m radius finds nothing in the mountains or deep
+ // countryside, which looks identical to being abroad and would suppress
+ // real warnings. GUGiK clamps this to its own 5 km maximum.
+ "radius": {"10000"},
+ }, &r)
+ if err != nil {
+ return "", StatusError, err
+ }
+ for _, entry := range r.Results {
+ if len(entry.Teryt) >= 4 {
+ return entry.Teryt[:4], StatusOK, nil
+ }
+ }
+ return "", StatusOutside, nil
+}
+
+// Warnings returns the warnings in force for a powiat.
+func Warnings(powiat string) ([]Warning, error) {
+ var all []Warning
+ if err := fetch(warningsURL, nil, &all); err != nil {
+ return nil, err
+ }
+ cut := now()
+ var live []Warning
+ for _, w := range all {
+ if !w.covers(powiat) {
+ continue
+ }
+ // An expired warning is dropped; one whose date will not parse is kept,
+ // because showing a stale warning beats hiding a live one.
+ if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil {
+ if to.Before(cut) {
+ continue
+ }
+ }
+ live = append(live, w)
+ }
+ return live, nil
+}
+
+func (w Warning) covers(powiat string) bool {
+ for _, a := range w.Teryt {
+ switch v := a.(type) {
+ case string:
+ if v == powiat {
+ return true
+ }
+ case float64:
+ if strconv.Itoa(int(v)) == powiat {
+ return true
+ }
+ }
+ }
+ return false
+}