Files
Maven/internal/weather/openmeteo.go
T
claude ea9c746852 weather: any city he names, not the six in a table (V-421)
The hand-written table understood "какая погода в X" for six values of X.
Ask about Kazan or Tbilisi and the city was dropped silently and answered for
the default location — a correct-sounding answer about the wrong place.

The table is gone. internal/weather already calls Open-Meteo's geocoding
endpoint on every lookup, so the place he named goes straight there and any
place it knows is a place he can ask about. He speaks the prepositional case,
so locationCandidates reverses the two endings that cover most of it: a final
"е" is a nominative "а" or nothing, a final "и" is a soft sign. A wrong
candidate finds no city; it never invents one.

A place the geocoder does not have now reads as "не знаю такого города"
rather than as a provider outage or, worse, as the default city's weather.
ErrLocationUnknown is what carries that apart.

"в" followed by a room or a day word is still the default location. Those
questions are answered by the house sensors and the calendar, not by
Open-Meteo, and they must not be read as a city.
2026-08-04 03:25:42 +04:00

182 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type OpenMeteoProvider struct {
httpClient *http.Client
}
func NewOpenMeteoProvider() *OpenMeteoProvider {
return &OpenMeteoProvider{
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (p *OpenMeteoProvider) SetClient(c *http.Client) {
p.httpClient = c
}
type geoResult struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Country string `json:"country"`
}
type geoResponse struct {
Results []geoResult `json:"results"`
}
type currentWeather struct {
Temperature float64 `json:"temperature"`
WMO int `json:"weathercode"`
WindSpeed float64 `json:"windspeed"`
}
type forecastResponse struct {
CurrentWeather currentWeather `json:"current_weather"`
}
var wmoCodes = map[int]string{
0: "ясно",
1: "преимущественно ясно",
2: "облачно",
3: "пасмурно",
45: "туман",
51: "морось",
61: "дождь",
71: "снег",
95: "гроза",
}
func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) {
lat, lon, name, err := p.geocode(ctx, location)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err)
}
u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current_weather=true", lat, lon)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Weather{}, fmt.Errorf("open-meteo: read: %w", err)
}
var forecast forecastResponse
if err := json.Unmarshal(body, &forecast); err != nil {
return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
}
condition := wmoCodes[forecast.CurrentWeather.WMO]
if condition == "" {
condition = "неизвестно"
}
return Weather{
Location: name,
Temperature: forecast.CurrentWeather.Temperature,
Condition: condition,
}, nil
}
// locationCandidates — the spellings to try for a place taken out of a spoken
// sentence, in order. He says "какая погода в Казани", so the word arrives in
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
//
// Two cheap reversals cover most of what he says: a final "е" is usually a
// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a
// final "и" is usually a soft sign (Казани → Казань). Indeclinable names —
// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers.
//
// Nothing here is a guess about the weather: a wrong candidate finds no city
// and the caller says so. It only decides which strings are worth asking about.
func locationCandidates(location string) []string {
out := []string{location}
add := func(s string) {
if s == "" || s == location {
return
}
for _, seen := range out {
if seen == s {
return
}
}
out = append(out, s)
}
r := []rune(location)
if len(r) < 4 {
return out
}
stem := string(r[:len(r)-1])
switch r[len(r)-1] {
case 'е', 'Е':
add(stem + "а")
add(stem)
case 'и', 'И':
add(stem + "ь")
add(stem)
case 'у', 'У', 'ю', 'Ю':
add(stem + "а")
}
return out
}
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
for _, cand := range locationCandidates(location) {
lat, lon, name, err = p.geocodeOne(ctx, cand)
if err == nil {
return lat, lon, name, nil
}
}
return 0, 0, "", err
}
func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) {
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, 0, "", err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return 0, 0, "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, "", err
}
var geo geoResponse
if err := json.Unmarshal(body, &geo); err != nil {
return 0, 0, "", err
}
if len(geo.Results) == 0 {
return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location)
}
r := geo.Results[0]
return r.Latitude, r.Longitude, r.Name, nil
}