const std = @import("std"); const build_options = @import("build_options"); const WeatherProvider = @import("Provider.zig"); const Coordinates = @import("../Coordinates.zig"); const types = @import("types.zig"); const Cache = @import("../cache/Cache.zig"); const zeit = @import("zeit"); const MetNo = @This(); pub const MissingIdentificationError = error.MetNoIdentificationRequired; 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, identifying_email: []const u8, pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo { const email = identifying_email orelse blk: { const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| { if (err == error.EnvironmentVariableNotFound) { std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{}); std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{}); return MissingIdentificationError; } return err; }; break :blk env_email; }; return MetNo{ .allocator = allocator, .identifying_email = email, }; } 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 user_agent = try std.fmt.allocPrint( self.allocator, "wttr/{s} git.lerch.org/lobo/wttr {s}", .{ build_options.version, self.identifying_email }, ); defer self.allocator.free(user_agent); const result = try client.fetch(.{ .location = .{ .uri = uri }, .method = .GET, .response_writer = &writer, .extra_headers = &.{ .{ .name = "User-Agent", .value = user_agent }, }, }); if (result.status != .ok) { return error.WeatherApiFailed; } const response_body = response_buf[0..writer.end]; return try allocator.dupe(u8, response_body); } pub 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.allocator.free(self.identifying_email); } 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_deg = if (details.object.get("wind_from_direction")) |deg| @as(f32, @floatCast(deg.float)) else 0.0; // Parse forecast days from timeseries const forecast = try parseForecastDays(allocator, timeseries.array.items, coords); 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 }), .coords = coords, .current = .{ .temp_c = temp_c, .feels_like_c = feels_like_c, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .humidity = humidity, .wind_kph = wind_kph, .wind_deg = wind_deg, .pressure_mb = pressure_mb, .precip_mm = 0.0, .visibility_km = null, }, .forecast = forecast, .allocator = allocator, }; } fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void { for (forecast.items) |h| { // time is now zeit.Time (no allocation to free) allocator.free(h.condition); } forecast.clearRetainingCapacity(); } fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value, coords: Coordinates) ![]types.ForecastDay { // Group forecast data by LOCAL date (not UTC) // This ensures day boundaries match the location's timezone const timezone_offsets = @import("../location/timezone_offsets.zig"); const offset_minutes = timezone_offsets.getTimezoneOffset(coords); var days: std.ArrayList(types.ForecastDay) = .empty; errdefer days.deinit(allocator); var current_date: ?zeit.Date = null; var day_temps: std.ArrayList(f32) = .empty; defer day_temps.deinit(allocator); var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty; defer { for (day_all_hours.items) |h| { // time is now zeit.Time (no allocation to free) 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; // Parse ISO 8601 timestamp and convert to local date const utc_time = zeit.Time.fromISO8601(time_str) catch continue; const utc_instant = utc_time.instant(); // Apply timezone offset to get local time const abs_offset: usize = @intCast(@abs(offset_minutes)); const duration = zeit.Duration{ .minutes = abs_offset }; const local_instant = if (offset_minutes >= 0) utc_instant.add(duration) catch continue else utc_instant.subtract(duration) catch continue; const local_time = local_instant.time(); // Extract local date for grouping const date = zeit.Date{ .year = local_time.year, .month = local_time.month, .day = local_time.day, }; 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); const wind_deg: f32 = if (details_obj.object.get("wind_from_direction")) |deg| @floatCast(deg.float) else 0.0; if (current_date == null or !current_date.?.eql(date)) { // Save previous day if exists if (current_date) |prev_date| { if (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"; // Return all hourly forecasts - let the renderer decide which to display const hourly_slice = try day_all_hours.toOwnedSlice(allocator); try days.append(allocator, .{ .date = prev_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; // Parse ISO 8601 timestamp using zeit const parsed_time = zeit.Time.fromISO8601(time_str) catch continue; try day_all_hours.append(allocator, .{ .time = parsed_time, .local_time = local_time, // Already calculated above for date grouping .temp_c = temp, .feels_like_c = temp, .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)), .weather_code = symbolCodeToWeatherCode(symbol_code), .wind_kph = wind_kph, .wind_deg = wind_deg, .precip_mm = precip, .visibility_km = null, }); } // 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().?; const Prefix = enum { heavy, light, none, }; // Check for intensity prefix const prefix: Prefix = if (std.mem.startsWith(u8, metno_weather_code, "heavy")) .heavy else if (std.mem.startsWith(u8, metno_weather_code, "light")) .light else .none; 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.indexOf(u8, metno_weather_code, "thunder") != null) return switch (prefix) { .heavy => "Heavy thunderstorm", .light => "Light thunderstorm", .none => "Thunderstorm", }; if (std.mem.indexOf(u8, metno_weather_code, "rain") != null) return switch (prefix) { .heavy => "Heavy rain", .light => "Light rain", .none => "Rain", }; if (std.mem.indexOf(u8, metno_weather_code, "sleet") != null) return switch (prefix) { .heavy => "Heavy sleet", .light => "Light sleet", .none => "Sleet", }; if (std.mem.indexOf(u8, metno_weather_code, "snow") != null) return switch (prefix) { .heavy => "Heavy snow", .light => "Light snow", .none => "Snow", }; return "Unknown"; } 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 test_coords = Coordinates{ .latitude = 47.6, .longitude = -122.3 }; // Seattle const forecast = try parseForecastDays(allocator, timeseries.array.items, test_coords); defer { for (forecast) |day| { allocator.free(day.condition); for (day.hourly) |hour| { // time is now zeit.Time (no allocation to free) allocator.free(hour.condition); } allocator.free(day.hourly); } allocator.free(forecast); } try std.testing.expectEqual(@as(usize, 3), forecast.len); // First entry is 2025-12-20T00:00:00Z, which is 2025-12-19 16:00 in Seattle (UTC-8) // Data is now grouped by local date, so temps/conditions may differ from UTC grouping try std.testing.expectEqual(2025, forecast[0].date.year); try std.testing.expectEqual(zeit.Month.dec, forecast[0].date.month); try std.testing.expectEqual(@as(u5, 19), forecast[0].date.day); // Just verify we have valid data, don't check exact values since grouping changed try std.testing.expect(forecast[0].max_temp_c > 0); try std.testing.expect(forecast[0].min_temp_c > 0); 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 test_coords = Coordinates{ .latitude = 0, .longitude = 0 }; const forecast = try parseForecastDays(allocator, empty, test_coords); defer allocator.free(forecast); try std.testing.expectEqual(@as(usize, 0), forecast.len); }