Add OpenWeather-backed weather reports

This commit is contained in:
Jacob Dubin
2026-05-06 08:13:26 -05:00
parent 699e0d5282
commit b74ef3bfa2
15 changed files with 2072 additions and 55 deletions

View File

@@ -21,6 +21,14 @@
"WhisperLanguage": "en",
"TempDirectory": "/tmp/openjibo-stt",
"CleanupTempFiles": false
},
"Weather": {
"OpenWeather": {
"BaseUrl": "https://api.openweathermap.org",
"ApiKey": "723667c9ab0318142227c5389900d087",
"DefaultLocation": "Boston,US",
"UseCelsius": false
}
}
}
}

View File

@@ -6,6 +6,20 @@ public interface IPersonalMemoryStore
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
string? GetPreference(PersonalMemoryTenantScope tenantScope, string category);
void SetName(PersonalMemoryTenantScope tenantScope, string name);
string? GetName(PersonalMemoryTenantScope tenantScope);
void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value);
string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label);
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope);
}
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
public enum PersonalAffinity
{
Like,
Love,
Dislike
}

View File

@@ -0,0 +1,24 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface IWeatherReportProvider
{
Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default);
}
public sealed record WeatherReportRequest(
string? LocationQuery,
double? Latitude,
double? Longitude,
bool IsTomorrow,
bool? UseCelsius);
public sealed record WeatherReportSnapshot(
string LocationName,
string Summary,
int Temperature,
int? HighTemperature,
int? LowTemperature,
string? Condition,
bool UseCelsius);

View File

@@ -50,6 +50,13 @@ public sealed class ProtocolToTurnContextMapper
attributes["lastClockDomain"] = lastClockDomainText;
}
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
pendingProactivityOffer is string pendingProactivityOfferText &&
!string.IsNullOrWhiteSpace(pendingProactivityOfferText))
{
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
}
attributes["listenHotphrase"] = turnState.ListenHotphrase;
if (turnState.ListenRules.Count > 0)

View File

@@ -37,6 +37,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
var isPhotoCreateLaunch = string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase);
var isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
var isReportSkillLaunch = string.Equals(skill?.SkillName, "report-skill", StringComparison.OrdinalIgnoreCase);
var localIntent = ReadSkillPayloadString(skill, "localIntent");
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
var clockDomain = ReadSkillPayloadString(skill, "domain");
@@ -50,6 +51,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
var globalIntent = ReadSkillPayloadString(skill, "globalIntent");
var nluDomain = ReadSkillPayloadString(skill, "nluDomain");
var volumeLevel = ReadSkillPayloadString(skill, "volumeLevel");
var reportDate = ReadSkillPayloadString(skill, "date");
var reportWeatherCondition = ReadSkillPayloadString(skill, "weatherCondition");
var nluGuess = ReadClientEntity(turn, "guess");
var wordOfDayGuess = ResolveWordOfDayGuess(turn, transcript, nluGuess);
var outboundIntent = isGlobalCommand && !string.IsNullOrWhiteSpace(globalIntent)
@@ -64,6 +67,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? localIntent
: isClockSkillLaunch && !string.IsNullOrWhiteSpace(clockIntent)
? clockIntent
: isReportSkillLaunch && !string.IsNullOrWhiteSpace(localIntent)
? localIntent
: isWordOfDayGuess
? "guess"
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
@@ -112,6 +117,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
? rules
: []
: isReportSkillLaunch
? []
: isWordOfDayGuess
? ["word-of-the-day/puzzle"]
: isYesNoTurn && isYesNoIntent
@@ -136,7 +143,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
timerMinutes,
timerSeconds,
alarmTime,
alarmAmPm);
alarmAmPm,
isReportSkillLaunch,
reportDate,
reportWeatherCondition);
var listenMessage = new
{
type = "LISTEN",
@@ -159,6 +169,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
isPhotoGalleryLaunch ? "@be/gallery" :
isPhotoCreateLaunch ? "@be/create" :
isClockSkillLaunch ? "@be/clock" :
isReportSkillLaunch ? "report-skill" :
null,
isGlobalCommand ? nluDomain ?? "global_commands" : null),
match = new
@@ -286,6 +297,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
DelayMs: 125));
}
if (isReportSkillLaunch)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
transId,
"report-skill",
outboundIntent,
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "report-skill")),
DelayMs: 125));
}
if (emitSkillActions && speak is not null)
{
messages.Add(new SocketReplyPlan(
@@ -444,7 +471,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
string? timerMinutes,
string? timerSeconds,
string? alarmTime,
string? alarmAmPm)
string? alarmAmPm,
bool reportSkillLaunch,
string? reportDate,
string? reportWeatherCondition)
{
if (yesNoTurn)
{
@@ -514,6 +544,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
return entities;
}
if (reportSkillLaunch)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(reportDate))
{
entities["date"] = reportDate;
}
if (!string.IsNullOrWhiteSpace(reportWeatherCondition))
{
entities["Weather"] = reportWeatherCondition;
}
return entities;
}
if (wordOfDayGuess)
{
return new Dictionary<string, object?>

View File

@@ -538,6 +538,9 @@ public sealed partial class WebSocketTurnFinalizationService(
{
session.Metadata["lastClockDomain"] = lastClockDomainValue.ToString();
}
UpdatePendingProactivityOffer(session, plan.IntentName);
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout)
: null;
@@ -567,13 +570,13 @@ public sealed partial class WebSocketTurnFinalizationService(
!string.Equals(plan.IntentName, "alarm_cancel", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_clarify", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_clarify", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
(messageType != "CLIENT_NLU" ||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply
{
Text = map.Text,
@@ -812,6 +815,7 @@ public sealed partial class WebSocketTurnFinalizationService(
{
var messageType = ReadMessageType(turn);
var clientIntent = ReadAttribute(turn, "clientIntent");
var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer");
var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript);
var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray();
@@ -846,6 +850,12 @@ public sealed partial class WebSocketTurnFinalizationService(
return true;
}
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
transcript is "yes" or "no" or "sure" or "nope" or "yup" or "uh huh" or "yeah" or "nah")
{
return true;
}
if (listenRules.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase)))
{
return true;
@@ -960,6 +970,17 @@ public sealed partial class WebSocketTurnFinalizationService(
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase);
}
private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName)
{
if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase))
{
session.Metadata["pendingProactivityOffer"] = "pizza_fact";
return;
}
session.Metadata.Remove("pendingProactivityOffer");
}
private static IEnumerable<string> ReadRules(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)

View File

@@ -4,6 +4,7 @@ using Jibo.Cloud.Infrastructure.Audio;
using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Cloud.Infrastructure.Telemetry;
using Jibo.Cloud.Infrastructure.Weather;
using Jibo.Runtime.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
@@ -23,7 +24,20 @@ public static class ServiceCollectionExtensions
configuration.GetSection("OpenJibo:Stt").Bind(sttOptions);
}
var openWeatherOptions = new OpenWeatherOptions();
if (configuration is not null)
{
configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions);
}
if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey))
{
openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
}
services.AddSingleton(sttOptions);
services.AddSingleton(openWeatherOptions);
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));

View File

@@ -36,6 +36,62 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
: null;
}
public void SetName(PersonalMemoryTenantScope tenantScope, string name)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Name = name;
}
public string? GetName(PersonalMemoryTenantScope tenantScope)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) ? record.Name : null;
}
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.ImportantDates[NormalizeCategory(label)] = value;
}
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) &&
record.ImportantDates.TryGetValue(NormalizeCategory(label), out var value)
? value
: null;
}
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
{
var key = BuildTenantKey(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Affinities[NormalizeCategory(item)] = affinity;
}
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.TryGetValue(key, out var record) &&
record.Affinities.TryGetValue(NormalizeCategory(item), out var affinity)
? affinity
: null;
}
public IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope)
{
var key = BuildTenantKey(tenantScope);
if (!_tenantMemory.TryGetValue(key, out var record))
{
return new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase);
}
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
{
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
@@ -49,6 +105,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
private sealed class TenantMemoryRecord
{
public string? Birthday { get; set; }
public string? Name { get; set; }
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,12 @@
namespace Jibo.Cloud.Infrastructure.Weather;
public sealed class OpenWeatherOptions
{
public string BaseUrl { get; set; } = "https://api.openweathermap.org";
public string? ApiKey { get; set; }
public string DefaultLocation { get; set; } = "Boston,US";
public bool UseCelsius { get; set; }
}

View File

@@ -0,0 +1,364 @@
using System.Globalization;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Jibo.Cloud.Infrastructure.Weather;
public sealed class OpenWeatherReportProvider(
HttpClient httpClient,
OpenWeatherOptions options,
ILogger<OpenWeatherReportProvider> logger)
: IWeatherReportProvider
{
public async Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(options.ApiKey))
{
return null;
}
try
{
var location = await ResolveLocationAsync(request, cancellationToken);
if (location is null)
{
return null;
}
var useCelsius = request.UseCelsius ?? options.UseCelsius;
return request.IsTomorrow
? await GetTomorrowForecastAsync(location.Value, useCelsius, cancellationToken)
: await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken);
}
catch (Exception exception)
{
logger.LogWarning(exception, "OpenWeather lookup failed.");
return null;
}
}
private async Task<LocationPoint?> ResolveLocationAsync(
WeatherReportRequest request,
CancellationToken cancellationToken)
{
if (request is { Latitude: not null, Longitude: not null })
{
return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null);
}
var query = string.IsNullOrWhiteSpace(request.LocationQuery)
? options.DefaultLocation
: request.LocationQuery.Trim();
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
var geocodeUri = BuildRequestUri(
"/geo/1.0/direct",
("q", query),
("limit", "1"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(geocodeUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (document.RootElement.ValueKind != JsonValueKind.Array ||
document.RootElement.GetArrayLength() == 0)
{
return null;
}
var location = document.RootElement[0];
if (!TryReadDouble(location, "lat", out var latitude) ||
!TryReadDouble(location, "lon", out var longitude))
{
return null;
}
var displayName = BuildLocationDisplayName(location);
return new LocationPoint(latitude, longitude, displayName);
}
private async Task<WeatherReportSnapshot?> GetCurrentWeatherAsync(
LocationPoint location,
bool useCelsius,
CancellationToken cancellationToken)
{
var weatherUri = BuildRequestUri(
"/data/2.5/weather",
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(weatherUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement;
if (!root.TryGetProperty("main", out var main))
{
return null;
}
var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation;
var summary = TryReadWeatherSummary(root);
var condition = TryReadWeatherCondition(root);
var temperature = TryReadInt(main, "temp");
var high = TryReadInt(main, "temp_max");
var low = TryReadInt(main, "temp_min");
if (temperature is null && high is null && low is null)
{
return null;
}
var resolvedTemperature = temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot(
locationName,
summary ?? "partly cloudy",
resolvedTemperature,
high,
low,
condition,
useCelsius);
}
private async Task<WeatherReportSnapshot?> GetTomorrowForecastAsync(
LocationPoint location,
bool useCelsius,
CancellationToken cancellationToken)
{
var forecastUri = BuildRequestUri(
"/data/2.5/forecast",
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(forecastUri, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement;
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
{
return null;
}
var offset = TryReadForecastOffset(root);
var tomorrow = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(1));
var entries = new List<ForecastEntry>();
foreach (var item in list.EnumerateArray())
{
if (!TryReadLong(item, "dt", out var unixSeconds))
{
continue;
}
var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset);
if (DateOnly.FromDateTime(localTimestamp.DateTime) != tomorrow)
{
continue;
}
if (!item.TryGetProperty("main", out var main))
{
continue;
}
entries.Add(new ForecastEntry(
localTimestamp,
TryReadInt(main, "temp"),
TryReadInt(main, "temp_max"),
TryReadInt(main, "temp_min"),
TryReadWeatherSummary(item),
TryReadWeatherCondition(item)));
}
if (entries.Count == 0)
{
return null;
}
var selectedEntry = entries
.OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes))
.First();
var highs = entries
.Where(entry => entry.HighTemperature is not null)
.Select(entry => entry.HighTemperature!.Value)
.ToArray();
var lows = entries
.Where(entry => entry.LowTemperature is not null)
.Select(entry => entry.LowTemperature!.Value)
.ToArray();
var locationName = ReadForecastLocationName(root) ?? location.DisplayName ?? options.DefaultLocation;
var high = highs.Length > 0 ? highs.Max() : selectedEntry.HighTemperature;
var low = lows.Length > 0 ? lows.Min() : selectedEntry.LowTemperature;
var temperature = selectedEntry.Temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot(
locationName,
selectedEntry.Summary ?? "partly cloudy",
temperature,
high,
low,
selectedEntry.Condition,
useCelsius);
}
private Uri BuildRequestUri(string path, params (string Key, string Value)[] queryParts)
{
var baseUrl = options.BaseUrl.TrimEnd('/');
var query = string.Join(
"&",
queryParts.Select(part =>
$"{Uri.EscapeDataString(part.Key)}={Uri.EscapeDataString(part.Value)}"));
return new Uri($"{baseUrl}{path}?{query}");
}
private static TimeSpan TryReadForecastOffset(JsonElement root)
{
if (!root.TryGetProperty("city", out var city))
{
return TimeSpan.Zero;
}
var timezoneSeconds = TryReadInt(city, "timezone");
if (timezoneSeconds is null)
{
return TimeSpan.Zero;
}
var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400);
return TimeSpan.FromSeconds(seconds);
}
private static string? ReadForecastLocationName(JsonElement root)
{
if (!root.TryGetProperty("city", out var city))
{
return null;
}
var name = ReadNonEmptyString(city, "name");
var country = ReadNonEmptyString(city, "country");
return string.IsNullOrWhiteSpace(country) ? name : $"{name}, {country}";
}
private static string? BuildLocationDisplayName(JsonElement location)
{
var name = ReadNonEmptyString(location, "name");
var state = ReadNonEmptyString(location, "state");
var country = ReadNonEmptyString(location, "country");
if (!string.IsNullOrWhiteSpace(name) &&
!string.IsNullOrWhiteSpace(state) &&
!string.IsNullOrWhiteSpace(country))
{
return $"{name}, {state}, {country}";
}
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country))
{
return $"{name}, {country}";
}
return name;
}
private static string? TryReadWeatherSummary(JsonElement root)
{
return TryReadWeatherProperty(root, "description");
}
private static string? TryReadWeatherCondition(JsonElement root)
{
var main = TryReadWeatherProperty(root, "main");
if (string.IsNullOrWhiteSpace(main))
{
return null;
}
var normalized = main.Trim().ToLowerInvariant();
return normalized switch
{
"rain" or "drizzle" or "thunderstorm" => "rain",
"snow" => "snow",
"clear" => "sunny",
"clouds" => "cloudy",
"mist" or "smoke" or "haze" or "fog" => "fog",
_ => normalized
};
}
private static string? TryReadWeatherProperty(JsonElement root, string key)
{
if (!root.TryGetProperty("weather", out var weather) ||
weather.ValueKind != JsonValueKind.Array ||
weather.GetArrayLength() == 0)
{
return null;
}
var first = weather[0];
return ReadNonEmptyString(first, key);
}
private static string? ReadNonEmptyString(JsonElement source, string key)
{
return source.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
private static bool TryReadDouble(JsonElement source, string key, out double value)
{
value = 0;
return source.TryGetProperty(key, out var element) && element.TryGetDouble(out value);
}
private static bool TryReadLong(JsonElement source, string key, out long value)
{
value = 0;
return source.TryGetProperty(key, out var element) && element.TryGetInt64(out value);
}
private static int? TryReadInt(JsonElement source, string key)
{
if (!source.TryGetProperty(key, out var element))
{
return null;
}
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric))
{
return (int)Math.Round(numeric, MidpointRounding.AwayFromZero);
}
return null;
}
private readonly record struct LocationPoint(double Latitude, double Longitude, string? DisplayName);
private sealed record ForecastEntry(
DateTimeOffset LocalTime,
int? Temperature,
int? HighTemperature,
int? LowTemperature,
string? Summary,
string? Condition);
}