diff --git a/MISSING_FEATURES.md b/MISSING_FEATURES.md index 26f0b7b..7f199f3 100644 --- a/MISSING_FEATURES.md +++ b/MISSING_FEATURES.md @@ -2,24 +2,20 @@ Features not yet implemented in the Zig version: -## 1. Prometheus Metrics Format (format=p1) -- Export weather data in Prometheus metrics format -- See API_ENDPOINTS.md for format specification - -## 2. PNG Generation +## 1. PNG Generation - Render weather reports as PNG images - Support transparency and custom styling - Requires image rendering library integration -## 3. Language/Localization +## 2. Language/Localization - Accept-Language header parsing - lang query parameter support - Translation of weather conditions and text (54 languages) -## 4. Json output +## 3. Json output - Does not match wttr.in format -## 5. Moon endpoint +## 4. Moon endpoint - `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented - Moon phase calculation is implemented and available in custom format (%m, %M) diff --git a/README.md b/README.md index ce3d305..1ad8205 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,6 @@ The result will look like: ## Prometheus Metrics Output -**Note:** Not yet implemented - see [MISSING_FEATURES.md](MISSING_FEATURES.md) - To fetch information in Prometheus format: ```bash diff --git a/src/Moon.zig b/src/Moon.zig index 4ad9b8c..bc5e628 100644 --- a/src/Moon.zig +++ b/src/Moon.zig @@ -28,6 +28,28 @@ pub const Phase = struct { pub fn day(self: Phase) u8 { return @intFromFloat(@round(self.age_days)); } + + pub fn format(self: Phase, writer: *std.Io.Writer) std.Io.Writer.Error!void { + const name = if (self.phase < 0.0625) + "New Moon" + else if (self.phase < 0.1875) + "Waxing Crescent" + else if (self.phase < 0.3125) + "First Quarter" + else if (self.phase < 0.4375) + "Waxing Gibbous" + else if (self.phase < 0.5625) + "Full Moon" + else if (self.phase < 0.6875) + "Waning Gibbous" + else if (self.phase < 0.8125) + "Last Quarter" + else if (self.phase < 0.9375) + "Waning Crescent" + else + "New Moon"; + try writer.print("{s}", .{name}); + } }; pub fn getPhase(timestamp: i64) Phase { diff --git a/src/http/handler.zig b/src/http/handler.zig index 019a246..246882e 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -8,6 +8,7 @@ const Line = @import("../render/Line.zig"); const Json = @import("../render/Json.zig"); const V2 = @import("../render/V2.zig"); const Custom = @import("../render/Custom.zig"); +const Prometheus = @import("../render/Prometheus.zig"); const help = @import("help.zig"); const log = std.log.scoped(.handler); @@ -147,6 +148,8 @@ fn handleWeatherInternal( res.content_type = .JSON; // reset to json break :blk try Json.render(req_alloc, weather); } + if (std.mem.eql(u8, fmt, "p1")) + break :blk try Prometheus.render(req_alloc, weather); if (std.mem.eql(u8, fmt, "v2")) break :blk try V2.render(req_alloc, weather, render_options.use_imperial); if (std.mem.startsWith(u8, fmt, "%")) diff --git a/src/render/Prometheus.zig b/src/render/Prometheus.zig new file mode 100644 index 0000000..94ecdde --- /dev/null +++ b/src/render/Prometheus.zig @@ -0,0 +1,196 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); +const Moon = @import("../Moon.zig"); + +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + const writer = output.writer(allocator); + + // Current conditions + try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{}); + try writer.print("temperature_feels_like_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.feels_like_c}); + + try writer.print("# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit\n", .{}); + try writer.print("temperature_feels_like_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()}); + + try writer.print("# HELP cloudcover_percentage Cloud Coverage in Percent\n", .{}); + try writer.print("cloudcover_percentage{{forecast=\"current\"}} 0\n", .{}); // Not in our data + + try writer.print("# HELP humidity_percentage Humidity in Percent\n", .{}); + try writer.print("humidity_percentage{{forecast=\"current\"}} {d}\n", .{weather.current.humidity}); + + try writer.print("# HELP precipitation_mm Precipitation (Rainfall) in mm\n", .{}); + try writer.print("precipitation_mm{{forecast=\"current\"}} {d:.1}\n", .{weather.current.precip_mm}); + + try writer.print("# HELP pressure_hpa Air pressure in hPa\n", .{}); + try writer.print("pressure_hpa{{forecast=\"current\"}} {d}\n", .{weather.current.pressure_mb}); + + try writer.print("# HELP temperature_celsius Temperature in Celsius\n", .{}); + try writer.print("temperature_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.temp_c}); + + try writer.print("# HELP temperature_fahrenheit Temperature in Fahrenheit\n", .{}); + try writer.print("temperature_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()}); + + try writer.print("# HELP uv_index Ultraviolet Radiation Index\n", .{}); + try writer.print("uv_index{{forecast=\"current\"}} 0\n", .{}); // Not in our data + + if (weather.current.visibility_km) |vis| { + try writer.print("# HELP visibility Visible Distance in Kilometres\n", .{}); + try writer.print("visibility{{forecast=\"current\"}} {d}\n", .{vis}); + } + + try writer.print("# HELP weather_code Code to describe Weather Condition\n", .{}); + try writer.print("weather_code{{forecast=\"current\"}} {d}\n", .{@intFromEnum(weather.current.weather_code)}); + + try writer.print("# HELP winddir_degree Wind Direction in Degree\n", .{}); + try writer.print("winddir_degree{{forecast=\"current\"}} {d}\n", .{weather.current.wind_deg}); + + try writer.print("# HELP windspeed_kmph Wind Speed in Kilometres per Hour\n", .{}); + try writer.print("windspeed_kmph{{forecast=\"current\"}} {d}\n", .{weather.current.wind_kph}); + + try writer.print("# HELP windspeed_mph Wind Speed in Miles per Hour\n", .{}); + try writer.print("windspeed_mph{{forecast=\"current\"}} {d}\n", .{weather.current.windMph()}); + + try writer.print("# HELP observation_time Minutes since start of the day the observation happened\n", .{}); + try writer.print("observation_time{{forecast=\"current\"}} 0\n", .{}); // Not tracked + + try writer.print("# HELP weather_desc Weather Description\n", .{}); + try writer.print("weather_desc{{forecast=\"current\", description=\"{s}\"}} 1\n", .{weather.current.condition}); + + try writer.print("# HELP winddir_16_point Wind Direction on a 16-wind compass rose\n", .{}); + const wind_dir = degreeToDirection(weather.current.wind_deg); + try writer.print("winddir_16_point{{forecast=\"current\", description=\"{s}\"}} 1\n", .{wind_dir}); + + // Forecast days + for (weather.forecast, 0..) |day, i| { + const forecast_label = try std.fmt.allocPrint(allocator, "{d}d", .{i}); + defer allocator.free(forecast_label); + + try writer.print("uv_index{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not in our data + + try writer.print("# HELP temperature_celsius_maximum Maximum Temperature in Celsius\n", .{}); + try writer.print("temperature_celsius_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.max_temp_c }); + + try writer.print("# HELP temperature_fahrenheit_maximum Maximum Temperature in Fahrenheit\n", .{}); + try writer.print("temperature_fahrenheit_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.maxTempFahrenheit() }); + + try writer.print("# HELP temperature_celsius_minimum Minimum Temperature in Celsius\n", .{}); + try writer.print("temperature_celsius_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.min_temp_c }); + + try writer.print("# HELP temperature_fahrenheit_minimum Minimum Temperature in Fahrenheit\n", .{}); + try writer.print("temperature_fahrenheit_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.minTempFahrenheit() }); + + try writer.print("# HELP sun_hour Hours of sunlight\n", .{}); + try writer.print("sun_hour{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not calculated + + try writer.print("# HELP snowfall_cm Total snowfall in cm\n", .{}); + try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data + + // Moon phase - use current time for simplicity + const timestamp = std.time.timestamp(); + const moon = Moon.getPhase(timestamp); + + try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{}); + try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 }); + + try writer.print("# HELP astronomy_moon_phase Phase of the moon\n", .{}); + try writer.print("astronomy_moon_phase{{forecast=\"{s}\", description=\"{f}\"}} 1\n", .{ forecast_label, moon }); + + try writer.print("# HELP astronomy_moonrise_min Minutes since start of the day until the moon appears above the horizon\n", .{}); + try writer.print("astronomy_moonrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated + + try writer.print("# HELP astronomy_moonset_min Minutes since start of the day until the moon disappears below the horizon\n", .{}); + try writer.print("astronomy_moonset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated + + try writer.print("# HELP astronomy_sunrise_min Minutes since start of the day until the sun appears above the horizon\n", .{}); + try writer.print("astronomy_sunrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated + + try writer.print("# HELP astronomy_sunset_min Minutes since start of the day until the moon disappears below the horizon\n", .{}); + try writer.print("astronomy_sunset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated + } + + return output.toOwnedSlice(allocator); +} + +fn degreeToDirection(degrees: f64) []const u8 { + const normalized = @mod(degrees + 11.25, 360); + const index: usize = @intFromFloat(normalized / 22.5); + const directions = [_][]const u8{ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" }; + return directions[index]; +} + +test "prometheus format includes required metrics" { + const allocator = std.testing.allocator; + + var forecast_days = [_]types.ForecastDay{ + .{ + .date = .{ .year = 2024, .month = .jan, .day = 1 }, + .max_temp_c = 12.0, + .min_temp_c = 5.0, + .condition = "Partly cloudy", + .weather_code = .clouds_scattered, + .hourly = &[_]types.HourlyForecast{}, + }, + }; + + const weather = types.WeatherData{ + .location = "London", + .coords = .{ .latitude = 51.5074, .longitude = -0.1278 }, + .current = .{ + .temp_c = 10.0, + .feels_like_c = 8.0, + .condition = "Partly cloudy", + .weather_code = .clouds_scattered, + .humidity = 75, + .wind_kph = 15.0, + .wind_deg = 225.0, + .pressure_mb = 1013.0, + .precip_mm = 0.5, + .visibility_km = 10.0, + }, + .forecast = &forecast_days, + .allocator = allocator, + }; + + const output = try render(allocator, weather); + defer allocator.free(output); + + // Check for key metrics + try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius{forecast=\"current\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "humidity_percentage{forecast=\"current\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "windspeed_kmph{forecast=\"current\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius_maximum{forecast=\"0d\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "astronomy_moon_illumination{forecast=\"0d\"}") != null); +} + +test "prometheus format has proper help comments" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "Test", + .coords = .{ .latitude = 0, .longitude = 0 }, + .current = .{ + .temp_c = 20.0, + .feels_like_c = 20.0, + .condition = "Clear", + .weather_code = .clear, + .humidity = 50, + .wind_kph = 10.0, + .wind_deg = 0.0, + .pressure_mb = 1000.0, + .precip_mm = 0.0, + .visibility_km = null, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather); + defer allocator.free(output); + + // Check for HELP comments + try std.testing.expect(std.mem.indexOf(u8, output, "# HELP temperature_celsius Temperature in Celsius") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "# HELP humidity_percentage Humidity in Percent") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "# HELP pressure_hpa Air pressure in hPa") != null); +}