const std = @import("std"); const WeatherProvider = @import("Provider.zig"); const Coordinates = @import("../Coordinates.zig"); const types = @import("types.zig"); const Cache = @import("../cache/Cache.zig"); const MetNo = @This(); const MetNoOpenWeatherEntry = struct { []const u8, types.WeatherCode }; // symbol codes: https://github.com/metno/weathericons/tree/main/weather // they also have _day, _night and _polartwilight variants // // Openweathermap weather condition codes: // https://openweathermap.org/weather-conditions const weather_code_entries = [_]MetNoOpenWeatherEntry{ // zig fmt: off .{ "clearsky", .clear }, .{ "cloudy", .clouds_overcast }, .{ "fair", .clouds_few }, .{ "fog", .fog }, .{ "heavyrain", .rain_heavy }, .{ "heavyrainandthunder", .thunderstorm_heavy_rain }, .{ "heavyrainshowers", .thunderstorm_drizzle }, .{ "heavyrainshowersandthunder", .thunderstorm_heavy_drizzle }, .{ "heavysleet", .sleet }, .{ "heavysleetandthunder", .thunderstorm_heavy_rain }, .{ "heavysleetshowers", .sleet_shower }, .{ "heavysleetshowersandthunder", .thunderstorm_heavy_drizzle }, .{ "heavysnow", .snow_heavy }, .{ "heavysnowandthunder", .thunderstorm_heavy }, .{ "heavysnowshowers", .snow_shower_heavy }, .{ "heavysnowshowersandthunder", .thunderstorm_heavy }, .{ "lightrain", .rain_light }, .{ "lightrainandthunder", .thunderstorm_light_rain }, .{ "lightrainshowers", .rain_shower_light }, .{ "lightrainshowersandthunder", .thunderstorm_light_rain }, .{ "lightsleet", .sleet_shower_light }, .{ "lightsleetandthunder", .thunderstorm_light }, .{ "lightsleetshowers", .sleet_shower_light }, .{ "lightsnow", .snow_light }, .{ "lightsnowandthunder", .thunderstorm_light }, .{ "lightsnowshowers", .snow_shower_light }, .{ "lightssleetshowersandthunder", .thunderstorm_light }, .{ "lightssnowshowersandthunder", .thunderstorm_light }, .{ "partlycloudy", .clouds_few }, .{ "rain", .rain_moderate }, .{ "rainandthunder", .thunderstorm_rain }, .{ "rainshowers", .rain_shower }, .{ "rainshowersandthunder", .thunderstorm_drizzle }, .{ "sleet", .sleet }, .{ "sleetandthunder", .thunderstorm }, .{ "sleetshowers", .sleet_shower }, .{ "sleetshowersandthunder", .thunderstorm_drizzle }, .{ "snow", .snow }, .{ "snowandthunder", .thunderstorm }, .{ "snowshowers", .snow_shower }, .{ "snowshowersandthunder", .thunderstorm }, // zig fmt: on }; const WeatherCodeMap = std.StaticStringMap(types.WeatherCode); const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries); allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) !MetNo { return MetNo{ .allocator = allocator, }; } pub fn provider(self: *MetNo, cache: *Cache) WeatherProvider { return .{ .ptr = self, .cache = cache, .vtable = &.{ .fetchRaw = fetchRaw, .parse = parse, .deinit = deinitProvider, }, }; } fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 { const self: *MetNo = @ptrCast(@alignCast(ptr)); const url = try std.fmt.allocPrint( self.allocator, "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}", .{ coords.latitude, coords.longitude }, ); defer self.allocator.free(url); // Fetch weather data from met.no API var client = std.http.Client{ .allocator = self.allocator }; defer client.deinit(); const uri = try std.Uri.parse(url); var response_buf: [1024 * 1024]u8 = undefined; var writer = std.Io.Writer.fixed(&response_buf); const result = try client.fetch(.{ .location = .{ .uri = uri }, .method = .GET, .response_writer = &writer, .extra_headers = &.{ .{ .name = "User-Agent", .value = "wttr.in-zig/1.0 github.com/chubin/wttr.in" }, }, }); if (result.status != .ok) { return error.WeatherApiFailed; } const response_body = response_buf[0..writer.end]; return try allocator.dupe(u8, response_body); } fn parse(ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) !types.WeatherData { _ = ptr; // Parse JSON response const parsed = try std.json.parseFromSlice( std.json.Value, allocator, raw, .{}, ); defer parsed.deinit(); // Extract coordinates from the response (we don't have them passed in) const geometry = parsed.value.object.get("geometry") orelse return error.InvalidResponse; const coordinates = geometry.object.get("coordinates") orelse return error.InvalidResponse; const lon: f64 = coordinates.array.items[0].float; const lat: f64 = coordinates.array.items[1].float; const coords = Coordinates{ .latitude = lat, .longitude = lon }; return try parseMetNoResponse(allocator, coords, parsed.value); } fn deinitProvider(ptr: *anyopaque) void { const self: *MetNo = @ptrCast(@alignCast(ptr)); self.deinit(); } pub fn deinit(self: *MetNo) void { _ = self; } fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: std.json.Value) !types.WeatherData { const properties = json.object.get("properties") orelse return error.InvalidResponse; const timeseries = properties.object.get("timeseries") orelse return error.InvalidResponse; if (timeseries.array.items.len == 0) return error.InvalidResponse; const current = timeseries.array.items[0]; const data = current.object.get("data") orelse return error.InvalidResponse; const instant = data.object.get("instant") orelse return error.InvalidResponse; const details = instant.object.get("details") orelse return error.InvalidResponse; const temp_c: f32 = @floatCast(details.object.get("air_temperature").?.float); const humidity: u8 = @intFromFloat(details.object.get("relative_humidity").?.float); const wind_speed_ms = details.object.get("wind_speed").?.float; const wind_kph: f32 = @floatCast(wind_speed_ms * 3.6); const pressure_mb: f32 = @floatCast(details.object.get("air_pressure_at_sea_level").?.float); // Get weather symbol const next_1h = data.object.get("next_1_hours"); const symbol_code = if (next_1h) |n1h| n1h.object.get("summary").?.object.get("symbol_code").?.string else "clearsky_day"; // Get wind direction const wind_from_deg = details.object.get("wind_from_direction"); const wind_dir = if (wind_from_deg) |deg| degreeToDirection(@as(f32, @floatCast(deg.float))) else "N"; // Parse forecast days from timeseries const forecast = try parseForecastDays(allocator, timeseries.array.items); const feels_like_c = temp_c; // TODO: Calculate wind chill return types.WeatherData{ .location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }), .current = .{ .temp_c = temp_c, .temp_f = temp_c * 9.0 / 5.0 + 32.0, .feels_like_c = feels_like_c, .feels_like_f = feels_like_c * 9.0 / 5.0 + 32.0, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .humidity = humidity, .wind_kph = wind_kph, .wind_dir = try allocator.dupe(u8, wind_dir), .pressure_mb = pressure_mb, .precip_mm = 0.0, }, .forecast = forecast, .allocator = allocator, }; } fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void { for (forecast.items) |h| { allocator.free(h.time); allocator.free(h.condition); } forecast.clearRetainingCapacity(); } fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value) ![]types.ForecastDay { var days: std.ArrayList(types.ForecastDay) = .empty; errdefer days.deinit(allocator); var current_date: ?[]const u8 = null; var day_temps: std.ArrayList(f32) = .empty; defer day_temps.deinit(allocator); var day_hourly: std.ArrayList(types.HourlyForecast) = .empty; defer { for (day_hourly.items) |h| { allocator.free(h.time); allocator.free(h.condition); } day_hourly.deinit(allocator); } var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty; defer { for (day_all_hours.items) |h| { allocator.free(h.time); allocator.free(h.condition); } day_all_hours.deinit(allocator); } var day_symbol: ?[]const u8 = null; for (timeseries) |entry| { const time_str = entry.object.get("time").?.string; const date = time_str[0..10]; const hour = time_str[11..13]; const data = entry.object.get("data") orelse continue; const instant = data.object.get("instant") orelse continue; const details_obj = instant.object.get("details") orelse continue; const temp: f32 = @floatCast(details_obj.object.get("air_temperature").?.float); const wind_ms = details_obj.object.get("wind_speed") orelse continue; const wind_kph: f32 = @floatCast(wind_ms.float * 3.6); if (current_date == null or !std.mem.eql(u8, current_date.?, date)) { // Save previous day if exists if (current_date != null and day_temps.items.len > 0) { var max_temp: f32 = day_temps.items[0]; var min_temp: f32 = day_temps.items[0]; for (day_temps.items) |t| { if (t > max_temp) max_temp = t; if (t < min_temp) min_temp = t; } const symbol = day_symbol orelse "clearsky_day"; // Use preferred times if we have 4, otherwise pick 4 evenly spaced from all hours const hourly_slice: []types.HourlyForecast = blk: { if (day_hourly.items.len >= 4) { const hrs = try day_hourly.toOwnedSlice(allocator); clearHourlyForecast(allocator, &day_all_hours); break :blk hrs; } clearHourlyForecast(allocator, &day_all_hours); if (day_all_hours.items.len < 4) break :blk try day_all_hours.toOwnedSlice(allocator); // Pick 4 evenly spaced entries from day_all_hours if (day_all_hours.items.len >= 4) { const step = day_all_hours.items.len / 4; var selected: std.ArrayList(types.HourlyForecast) = .empty; try selected.append(allocator, day_all_hours.items[0]); try selected.append(allocator, day_all_hours.items[step]); try selected.append(allocator, day_all_hours.items[step * 2]); try selected.append(allocator, day_all_hours.items[step * 3]); const hrs = try selected.toOwnedSlice(allocator); // Free the rest for (day_all_hours.items, 0..) |h, i| { if (i != 0 and i != step and i != step * 2 and i != step * 3) { allocator.free(h.time); allocator.free(h.condition); } } day_all_hours.clearRetainingCapacity(); break :blk hrs; } break :blk try day_all_hours.toOwnedSlice(allocator); }; try days.append(allocator, .{ .date = try allocator.dupe(u8, current_date.?), .max_temp_c = max_temp, .min_temp_c = min_temp, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), .weather_code = symbolCodeToWeatherCode(symbol), .hourly = hourly_slice, }); if (days.items.len >= 3) break; } // Start new day current_date = date; day_temps.clearRetainingCapacity(); day_all_hours.clearRetainingCapacity(); day_symbol = null; } try day_temps.append(allocator, temp); // Collect ALL hourly forecasts const next_1h = data.object.get("next_1_hours"); if (next_1h) |n1h| { const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string; const precip = if (n1h.object.get("details")) |det| blk: { if (det.object.get("precipitation_amount")) |p| { break :blk @as(f32, @floatCast(p.float)); } break :blk @as(f32, 0.0); } else 0.0; try day_all_hours.append(allocator, .{ .time = try allocator.dupe(u8, time_str[11..16]), .temp_c = temp, .feels_like_c = temp, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .wind_kph = wind_kph, .precip_mm = precip, }); } // Collect preferred hourly forecasts for 06:00, 12:00, 18:00, 00:00 if (std.mem.eql(u8, hour, "06") or std.mem.eql(u8, hour, "12") or std.mem.eql(u8, hour, "18") or std.mem.eql(u8, hour, "00")) { if (next_1h) |n1h| { const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string; const precip = if (n1h.object.get("details")) |det| blk: { if (det.object.get("precipitation_amount")) |p| { break :blk @as(f32, @floatCast(p.float)); } break :blk @as(f32, 0.0); } else 0.0; try day_hourly.append(allocator, .{ .time = try allocator.dupe(u8, time_str[11..16]), .temp_c = temp, .feels_like_c = temp, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .wind_kph = wind_kph, .precip_mm = precip, }); } } // Get symbol for the day if (day_symbol == null) { const next_6h = data.object.get("next_6_hours"); if (next_6h) |n6h| { if (n6h.object.get("summary")) |summary| { day_symbol = summary.object.get("symbol_code").?.string; } } } } return days.toOwnedSlice(allocator); } fn symbolCodeToWeatherCode(symbol: []const u8) types.WeatherCode { var it = std.mem.splitScalar(u8, symbol, '_'); const metno_weather_code = it.next().?; const variant = it.next(); _ = variant; // discard day/night/polar twilight for now return weather_code_map.get(metno_weather_code) orelse .unknown; } fn symbolCodeToCondition(symbol: []const u8) []const u8 { var it = std.mem.splitScalar(u8, symbol, '_'); const metno_weather_code = it.next().?; if (std.mem.eql(u8, metno_weather_code, "clearsky")) return "Clear"; if (std.mem.eql(u8, metno_weather_code, "partlycloudy")) return "Partly cloudy"; if (std.mem.eql(u8, metno_weather_code, "fair")) return "Fair"; if (std.mem.eql(u8, metno_weather_code, "cloudy")) return "Cloudy"; if (std.mem.eql(u8, metno_weather_code, "fog")) return "Fog"; if (std.mem.eql(u8, metno_weather_code, "thunder")) return "Thunderstorm"; if (std.mem.eql(u8, metno_weather_code, "rain")) return "Rain"; if (std.mem.eql(u8, metno_weather_code, "sleet")) return "Sleet"; if (std.mem.eql(u8, metno_weather_code, "snow")) return "Snow"; return "Unknown"; } fn degreeToDirection(deg: f32) []const u8 { const normalized = @mod(deg + 22.5, 360.0); const idx: usize = @intFromFloat(normalized / 45.0); const directions = [_][]const u8{ "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; return directions[@min(idx, 7)]; } test "degreeToDirection" { try std.testing.expectEqualStrings("N", degreeToDirection(0)); try std.testing.expectEqualStrings("NE", degreeToDirection(45)); try std.testing.expectEqualStrings("E", degreeToDirection(90)); try std.testing.expectEqualStrings("SE", degreeToDirection(135)); try std.testing.expectEqualStrings("S", degreeToDirection(180)); try std.testing.expectEqualStrings("SW", degreeToDirection(225)); try std.testing.expectEqualStrings("W", degreeToDirection(270)); try std.testing.expectEqualStrings("NW", degreeToDirection(315)); } test "symbolCodeToWeatherCode" { try std.testing.expectEqual(types.WeatherCode.clear, symbolCodeToWeatherCode("clearsky_day")); try std.testing.expectEqual(types.WeatherCode.clouds_few, symbolCodeToWeatherCode("fair_night")); try std.testing.expectEqual(types.WeatherCode.clouds_overcast, symbolCodeToWeatherCode("cloudy")); try std.testing.expectEqual(types.WeatherCode.rain_light, symbolCodeToWeatherCode("lightrain")); try std.testing.expectEqual(types.WeatherCode.snow, symbolCodeToWeatherCode("snow")); } test "parseForecastDays extracts 3 days" { const allocator = std.testing.allocator; const json_str = \\{"properties":{"timeseries":[ \\ {"time":"2025-12-20T00:00:00Z","data":{"instant":{"details":{"air_temperature":10.0,"wind_speed":5.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, \\ {"time":"2025-12-20T06:00:00Z","data":{"instant":{"details":{"air_temperature":15.0,"wind_speed":6.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, \\ {"time":"2025-12-20T12:00:00Z","data":{"instant":{"details":{"air_temperature":20.0,"wind_speed":7.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, \\ {"time":"2025-12-20T18:00:00Z","data":{"instant":{"details":{"air_temperature":12.0,"wind_speed":5.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}}, \\ {"time":"2025-12-21T00:00:00Z","data":{"instant":{"details":{"air_temperature":8.0,"wind_speed":8.0}},"next_6_hours":{"summary":{"symbol_code":"rain"}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}}, \\ {"time":"2025-12-21T12:00:00Z","data":{"instant":{"details":{"air_temperature":14.0,"wind_speed":10.0}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}}, \\ {"time":"2025-12-22T00:00:00Z","data":{"instant":{"details":{"air_temperature":5.0,"wind_speed":4.0}},"next_6_hours":{"summary":{"symbol_code":"snow"}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}}, \\ {"time":"2025-12-22T12:00:00Z","data":{"instant":{"details":{"air_temperature":7.0,"wind_speed":3.0}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}}, \\ {"time":"2025-12-23T00:00:00Z","data":{"instant":{"details":{"air_temperature":6.0,"wind_speed":2.0}}}} \\]}} ; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{}); defer parsed.deinit(); const properties = parsed.value.object.get("properties").?; const timeseries = properties.object.get("timeseries").?; const forecast = try parseForecastDays(allocator, timeseries.array.items); defer { for (forecast) |day| { allocator.free(day.date); allocator.free(day.condition); for (day.hourly) |hour| { allocator.free(hour.time); allocator.free(hour.condition); } allocator.free(day.hourly); } allocator.free(forecast); } try std.testing.expectEqual(@as(usize, 3), forecast.len); try std.testing.expectEqualStrings("2025-12-20", forecast[0].date); try std.testing.expectEqual(@as(f32, 20.0), forecast[0].max_temp_c); try std.testing.expectEqual(@as(f32, 10.0), forecast[0].min_temp_c); try std.testing.expectEqual(types.WeatherCode.clear, forecast[0].weather_code); } test "parseForecastDays handles empty timeseries" { const allocator = std.testing.allocator; const empty: []std.json.Value = &.{}; const forecast = try parseForecastDays(allocator, empty); defer allocator.free(forecast); try std.testing.expectEqual(@as(usize, 0), forecast.len); }