diff --git a/irc2phpbb/data/marvin_strings.json b/irc2phpbb/data/marvin_strings.json index 9797511..e7ccd5a 100644 --- a/irc2phpbb/data/marvin_strings.json +++ b/irc2phpbb/data/marvin_strings.json @@ -208,6 +208,40 @@ "station_url": "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/station/65090/period/latest-hour/data.json", "current_weather_url": "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/1/station/65090/period/latest-hour/data.json", "weather_codes_url": "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/codes.json", + "wind_direction_url": "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/3/station/65090/period/latest-hour/data.json", + "wind_speed_url": "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/4/station/65090/period/latest-hour/data.json", + "forecast_url": "https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/15.589/lat/56.15/data.json?parameters=air_temperature,symbol_code,wind_from_direction,wind_speed×eries=8", + "no_significant_weather": "Inget signifikant väder observerat", + "compass": ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"], + "symbols": { + "1": "klart", + "2": "nästan klart", + "3": "molnväxling", + "4": "halvklart", + "5": "molnigt", + "6": "mulet", + "7": "dimma", + "8": "lätta skurar", + "9": "måttliga skurar", + "10": "kraftiga skurar", + "11": "åskväder", + "12": "lätta snöblandade skurar", + "13": "måttliga snöblandade skurar", + "14": "kraftiga snöblandade skurar", + "15": "lätta snöbyar", + "16": "måttliga snöbyar", + "17": "kraftiga snöbyar", + "18": "lätt regn", + "19": "måttligt regn", + "20": "kraftigt regn", + "21": "åska", + "22": "lätt snöblandat", + "23": "måttligt snöblandat", + "24": "kraftigt snöblandat", + "25": "lätt snöfall", + "26": "måttligt snöfall", + "27": "kraftigt snöfall" + }, "failed": "Något har hänt med SMHI, de svarar inte." }, diff --git a/irc2phpbb/marvin_actions.py b/irc2phpbb/marvin_actions.py index c16920a..d515810 100644 --- a/irc2phpbb/marvin_actions.py +++ b/irc2phpbb/marvin_actions.py @@ -11,6 +11,7 @@ import logging import random import re +import zoneinfo from importlib import resources as impresources @@ -289,30 +290,61 @@ def marvinSun(row): return msg +def windDirectionToCompass(degrees): + """ + Convert a wind direction in degrees to a compass arrow (↑, ↗, →, ...). + """ + compass = getString("smhi")["compass"] + index = round(degrees / (360 / len(compass))) % len(compass) + return compass[index] + + +def getCurrentWeather(): + """ + Fetch the current temperature, wind and weather observation for Karlskrona. + """ + station_req = requests.get(getString("smhi", "station_url"), timeout=5) + weather_code: int = int(station_req.json().get("value")[0].get("value")) + + weather_codes_req = requests.get(getString("smhi", "weather_codes_url"), timeout=5) + weather_codes_arr: list = weather_codes_req.json().get("entry") + + current_weather_req = requests.get(getString("smhi", "current_weather_url"), timeout=5) + temperature: str = current_weather_req.json().get("value")[0].get("value") + + wind_direction_req = requests.get(getString("smhi", "wind_direction_url"), timeout=5) + wind_direction: float = float(wind_direction_req.json().get("value")[0].get("value")) + + wind_speed_req = requests.get(getString("smhi", "wind_speed_url"), timeout=5) + wind_speed: str = wind_speed_req.json().get("value")[0].get("value") + + observation = "" + for code in weather_codes_arr: + if code.get("key") == weather_code: + observation = code.get("value") + + return temperature, wind_speed, windDirectionToCompass(wind_direction), observation + + def marvinWeather(row): """ Check what the weather prognosis looks like. """ msg = "" if any(r in row for r in ["väder", "vädret", "prognos", "prognosen", "smhi"]): - temperature = "" - observation = "" - try: - station_req = requests.get(getString("smhi", "station_url"), timeout=5) - weather_code: int = int(station_req.json().get("value")[0].get("value")) + temperature, wind_speed, compass_direction, observation = getCurrentWeather() + current_symbol, forecast = getWeatherForecast() - weather_codes_req = requests.get(getString("smhi", "weather_codes_url"), timeout=5) - weather_codes_arr: list = weather_codes_req.json().get("entry") + parts = [f"Karlskrona {temperature}° {current_symbol} " + f"{wind_speed} m/s {compass_direction}"] - current_weather_req = requests.get(getString("smhi", "current_weather_url"), timeout=5) - temperature: str = current_weather_req.json().get("value")[0].get("value") + if observation and observation != getString("smhi", "no_significant_weather"): + parts.append(observation) - for code in weather_codes_arr: - if code.get("key") == weather_code: - observation = code.get("value") + current = ". ".join(parts) - msg = f"Karlskrona just nu: {temperature} °C. {observation}." + msg = f"{current} · {forecast}." if forecast else f"{current}." except Exception as e: LOG.error("Failed to get weather: %s", e) @@ -321,6 +353,36 @@ def marvinWeather(row): return msg +def getWeatherForecast(): + """ + Get the current sky symbol plus a short summary of the weather forecast + for the coming hours. + """ + symbols = getString("smhi")["symbols"] + forecast_req = requests.get(getString("smhi", "forecast_url"), timeout=5) + time_series = forecast_req.json().get("timeSeries") + + current_symbol = symbols.get(str(time_series[0].get("data").get("symbol_code"))) + + # Pick two points a few hours apart instead of showing every hour. + step_indices = [3, 7] + selected_steps = [time_series[i] for i in step_indices if i < len(time_series)] + + stockholm = zoneinfo.ZoneInfo("Europe/Stockholm") + steps = [] + for step in selected_steps: + data = step.get("data") + local_time = datetime.datetime.fromisoformat(step.get("time")).astimezone(stockholm) + temperature = data.get("air_temperature") + symbol = symbols.get(str(data.get("symbol_code"))) + wind_speed = data.get("wind_speed") + wind_direction = windDirectionToCompass(data.get("wind_from_direction")) + steps.append(f"{local_time:%H:%M} {temperature}° {symbol} " + f"{wind_speed} m/s {wind_direction}") + + return current_symbol, " · ".join(steps) + + def marvinStrip(row): """ Get a comic strip. diff --git a/tests/resources/weather/codes_significant.json b/tests/resources/weather/codes_significant.json new file mode 100644 index 0000000..d516f8c --- /dev/null +++ b/tests/resources/weather/codes_significant.json @@ -0,0 +1,12 @@ +{ + "entry": [ + { + "key": 68, + "value": "Lätt regn" + }, + { + "key": 100, + "value": "Inget signifikant väder observerat" + } + ] +} diff --git a/tests/resources/weather/forecast.json b/tests/resources/weather/forecast.json new file mode 100644 index 0000000..07d9a5e --- /dev/null +++ b/tests/resources/weather/forecast.json @@ -0,0 +1,90 @@ +{ + "createdTime": "2026-08-23T21:15:00Z", + "referenceTime": "2026-08-23T21:15:00Z", + "geometry": { + "type": "Point", + "coordinates": [[15.589, 56.15]] + }, + "timeSeries": [ + { + "time": "2026-08-23T22:00:00Z", + "intervalParametersStartTime": "2026-08-23T21:00:00Z", + "data": { + "air_temperature": 15.8, + "symbol_code": 4, + "wind_from_direction": 340, + "wind_speed": 2.6 + } + }, + { + "time": "2026-08-23T23:00:00Z", + "intervalParametersStartTime": "2026-08-23T22:00:00Z", + "data": { + "air_temperature": 15.5, + "symbol_code": 4, + "wind_from_direction": 335, + "wind_speed": 2.8 + } + }, + { + "time": "2026-08-24T00:00:00Z", + "intervalParametersStartTime": "2026-08-23T23:00:00Z", + "data": { + "air_temperature": 15.3, + "symbol_code": 4, + "wind_from_direction": 330, + "wind_speed": 3.0 + } + }, + { + "time": "2026-08-24T01:00:00Z", + "intervalParametersStartTime": "2026-08-24T00:00:00Z", + "data": { + "air_temperature": 15.2, + "symbol_code": 5, + "wind_from_direction": 320, + "wind_speed": 3.1 + } + }, + { + "time": "2026-08-24T02:00:00Z", + "intervalParametersStartTime": "2026-08-24T01:00:00Z", + "data": { + "air_temperature": 15.0, + "symbol_code": 5, + "wind_from_direction": 315, + "wind_speed": 3.2 + } + }, + { + "time": "2026-08-24T03:00:00Z", + "intervalParametersStartTime": "2026-08-24T02:00:00Z", + "data": { + "air_temperature": 14.8, + "symbol_code": 6, + "wind_from_direction": 310, + "wind_speed": 3.3 + } + }, + { + "time": "2026-08-24T04:00:00Z", + "intervalParametersStartTime": "2026-08-24T03:00:00Z", + "data": { + "air_temperature": 14.7, + "symbol_code": 6, + "wind_from_direction": 305, + "wind_speed": 3.3 + } + }, + { + "time": "2026-08-24T05:00:00Z", + "intervalParametersStartTime": "2026-08-24T04:00:00Z", + "data": { + "air_temperature": 14.6, + "symbol_code": 6, + "wind_from_direction": 300, + "wind_speed": 3.4 + } + } + ] +} diff --git a/tests/resources/weather/station_significant.json b/tests/resources/weather/station_significant.json new file mode 100644 index 0000000..225c728 --- /dev/null +++ b/tests/resources/weather/station_significant.json @@ -0,0 +1,40 @@ +{ + "updated": 1729620000000, + "parameter": { + "key": "13", + "name": "Rådande väder", + "summary": "momentanvärde, 1 gång/tim resp 8 gånger/dygn", + "unit": "kod" + }, + "station": { + "key": "65090", + "name": "Karlskrona-Söderstjerna", + "owner": "Försvarsmakten", + "ownerCategory": "CLIMATE", + "measuringStations": "CORE", + "height": 2.0 + }, + "period": { + "key": "latest-hour", + "from": 1729616401000, + "to": 1729620000000, + "summary": "Data från senaste timmen", + "sampling": "Ej angivet" + }, + "position": [ + { + "from": 1267401600000, + "to": 1729621220000, + "height": 2.4, + "latitude": 56.15, + "longitude": 15.589 + } + ], + "value": [ + { + "date": 1729620000000, + "value": "68", + "quality": "G" + } + ] +} diff --git a/tests/resources/weather/winddirection.json b/tests/resources/weather/winddirection.json new file mode 100644 index 0000000..52e9e0f --- /dev/null +++ b/tests/resources/weather/winddirection.json @@ -0,0 +1,40 @@ +{ + "updated": 1787346000000, + "parameter": { + "key": "3", + "name": "Vindriktning", + "summary": "medelvärde 10 min, 1 gång/tim", + "unit": "grader" + }, + "station": { + "key": "65090", + "name": "Karlskrona-Söderstjerna", + "owner": "Försvarsmakten", + "ownerCategory": "CLIMATE", + "measuringStations": "CORE", + "height": 10.0 + }, + "period": { + "key": "latest-hour", + "from": 1787342401000, + "to": 1787346000000, + "summary": "Data från senaste timmen", + "sampling": "10 minuter" + }, + "position": [ + { + "from": 1267401600000, + "to": 1787347212000, + "height": 10.0, + "latitude": 56.15, + "longitude": 15.589 + } + ], + "value": [ + { + "date": 1787346000000, + "value": "225", + "quality": "G" + } + ] +} diff --git a/tests/resources/weather/windspeed.json b/tests/resources/weather/windspeed.json new file mode 100644 index 0000000..8509b33 --- /dev/null +++ b/tests/resources/weather/windspeed.json @@ -0,0 +1,40 @@ +{ + "updated": 1787346000000, + "parameter": { + "key": "4", + "name": "Vindhastighet", + "summary": "medelvärde 10 min, 1 gång/tim", + "unit": "meter per sekund" + }, + "station": { + "key": "65090", + "name": "Karlskrona-Söderstjerna", + "owner": "Försvarsmakten", + "ownerCategory": "CLIMATE", + "measuringStations": "CORE", + "height": 10.0 + }, + "period": { + "key": "latest-hour", + "from": 1787342401000, + "to": 1787346000000, + "summary": "Data från senaste timmen", + "sampling": "10 minuter" + }, + "position": [ + { + "from": 1267401600000, + "to": 1787347212000, + "height": 10.0, + "latitude": 56.15, + "longitude": 15.589 + } + ], + "value": [ + { + "date": 1787346000000, + "value": "3.2", + "quality": "G" + } + ] +} diff --git a/tests/test_weather.py b/tests/test_weather.py index 6e74304..1d7e00f 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -20,13 +20,16 @@ def testWeatherRequest(self): self.executeAction(marvin_actions.marvinWeather, "väder") for url in ["https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/station/65090/period/latest-hour/data.json", "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/13/codes.json", - "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/1/station/65090/period/latest-hour/data.json"]: + "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/1/station/65090/period/latest-hour/data.json", + "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/3/station/65090/period/latest-hour/data.json", + "https://opendata-download-metobs.smhi.se/api/version/1.0/parameter/4/station/65090/period/latest-hour/data.json"]: self.assertTrue(mock.call(url, timeout=5) in r.get.call_args_list) def testWeatherResponse(self): - """Test that marvin properly parses weather responses""" + """Test that marvin properly parses weather responses, and skips the observation + sentence when nothing significant was observed""" responses = [] - for responseFile in ["station.json", "codes.json", "weather.json"]: + for responseFile in ["station.json", "codes.json", "weather.json", "winddirection.json", "windspeed.json", "forecast.json"]: path = os.path.join(os.path.dirname(__file__), "resources", "weather", responseFile) with open(path, "r", encoding="UTF-8") as f: response = requests.models.Response() @@ -35,5 +38,25 @@ def testWeatherResponse(self): with mock.patch("irc2phpbb.marvin_actions.requests") as r: r.get.side_effect = responses - expected = "Karlskrona just nu: 16.6 °C. Inget signifikant väder observerat." + expected = ("Karlskrona 16.6° halvklart 3.2 m/s ↙ · " + "03:00 15.2° molnigt 3.1 m/s ↖ · " + "07:00 14.6° mulet 3.4 m/s ↖.") + self.assertActionOutput(marvin_actions.marvinWeather, "väder", expected) + + def testWeatherResponseWithSignificantWeather(self): + """Test that the observation sentence is kept when there actually is significant weather""" + responses = [] + for responseFile in ["station_significant.json", "codes_significant.json", "weather.json", + "winddirection.json", "windspeed.json", "forecast.json"]: + path = os.path.join(os.path.dirname(__file__), "resources", "weather", responseFile) + with open(path, "r", encoding="UTF-8") as f: + response = requests.models.Response() + response._content = str.encode(json.dumps(json.load(f))) + responses.append(response) + + with mock.patch("irc2phpbb.marvin_actions.requests") as r: + r.get.side_effect = responses + expected = ("Karlskrona 16.6° halvklart 3.2 m/s ↙. Lätt regn · " + "03:00 15.2° molnigt 3.1 m/s ↖ · " + "07:00 14.6° mulet 3.4 m/s ↖.") self.assertActionOutput(marvin_actions.marvinWeather, "väder", expected)