← Back to Files
NightscoutService.cs
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using KaiChat.Models;
namespace KaiChat.Services;
public interface INightscoutService
{
Task<NightscoutReadingsResult> GetLatestAsync(int count = 6, CancellationToken ct = default);
Task<NightscoutReadingsResult> GetHistoryAsync(string? start, string? end, int count = 288, CancellationToken ct = default);
Task<NightscoutStatusResult> GetStatusAsync(CancellationToken ct = default);
string FormatReadingsForTool(NightscoutReadingsResult result);
string FormatStatusForTool(NightscoutStatusResult result);
}
public class NightscoutService : INightscoutService
{
private const int MaxLatestCount = 24;
private const int MaxHistoryCount = 1000;
private readonly HttpClient _http;
private readonly KaiChatConfig _config;
private readonly ILogger<NightscoutService> _logger;
public NightscoutService(HttpClient http, KaiChatConfig config, ILogger<NightscoutService> logger)
{
_http = http;
_config = config;
_logger = logger;
}
public async Task<NightscoutReadingsResult> GetLatestAsync(int count = 6, CancellationToken ct = default)
{
count = Math.Clamp(count, 1, MaxLatestCount);
return await FetchReadingsAsync($"/entries/sgv.json?count={count}", count, null, null, ct);
}
public async Task<NightscoutReadingsResult> GetHistoryAsync(string? start, string? end, int count = 288, CancellationToken ct = default)
{
count = Math.Clamp(count <= 0 ? 288 : count, 1, MaxHistoryCount);
var parsedEnd = ParseToolDate(end) ?? DateTimeOffset.Now;
var parsedStart = ParseToolDate(start) ?? parsedEnd.AddHours(-24);
if (parsedStart > parsedEnd)
(parsedStart, parsedEnd) = (parsedEnd, parsedStart);
var startUtc = parsedStart.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
var endUtc = parsedEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
var query = string.Join("&", new[]
{
$"count={count}",
$"find[dateString][$gte]={Uri.EscapeDataString(startUtc)}",
$"find[dateString][$lte]={Uri.EscapeDataString(endUtc)}"
});
return await FetchReadingsAsync($"/entries/sgv.json?{query}", count, parsedStart, parsedEnd, ct);
}
public async Task<NightscoutStatusResult> GetStatusAsync(CancellationToken ct = default)
{
var result = new NightscoutStatusResult
{
Configured = IsConfigured(),
FetchedAt = DateTimeOffset.Now,
BaseUrl = _config.Nightscout.BaseUrl
};
if (!result.Configured)
{
result.Message = "Nightscout is not configured. Set Nightscout URL and API-SECRET in Settings.";
return result;
}
try
{
using var req = BuildRequest("/status.json");
using var res = await _http.SendAsync(req, ct);
result.HttpStatus = (int)res.StatusCode;
var body = await res.Content.ReadAsStringAsync(ct);
result.Success = res.IsSuccessStatusCode;
if (!res.IsSuccessStatusCode)
{
result.Message = $"Nightscout status request failed: HTTP {(int)res.StatusCode} {res.ReasonPhrase}.";
result.BodyPreview = Trim(body, 1000);
return result;
}
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
result.Version = TryGetString(root, "version");
result.ServerTime = TryGetString(root, "serverTime");
result.ApiEnabled = TryGetBool(root, "apiEnabled");
result.Message = "Nightscout status request succeeded.";
result.BodyPreview = Trim(body, 1000);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nightscout status request failed");
result.Message = $"Nightscout status request failed: {ex.Message}";
return result;
}
}
public string FormatReadingsForTool(NightscoutReadingsResult result)
{
if (!result.Configured)
return result.Message;
if (!result.Success)
return $"{result.Message}\nSource: {result.SourceUrl}";
if (result.Readings.Count == 0)
return $"Nightscout returned no CGM readings.\nSource: {result.SourceUrl}";
var lines = new List<string>
{
"Nightscout CGM readings (read-only; CGM data can lag or be inaccurate, do not infer insulin dosing or urgent medical decisions from this alone):",
$"Fetched: {FormatDateTime(result.FetchedAt)}",
$"Source: {result.SourceUrl}",
$"Returned: {result.Readings.Count} reading(s)"
};
if (result.Start.HasValue || result.End.HasValue)
lines.Add($"Window: {FormatNullableDateTime(result.Start)} to {FormatNullableDateTime(result.End)}");
if (result.Summary != null)
{
lines.Add($"Summary: min {FormatGlucose((int?)result.Summary.MinSgv)}, max {FormatGlucose((int?)result.Summary.MaxSgv)}, average {FormatGlucose(result.Summary.AverageSgv)}");
}
lines.Add("");
lines.Add("Readings:");
foreach (var reading in result.Readings)
lines.Add($"- {FormatReading(reading)}");
return string.Join("\n", lines);
}
public string FormatStatusForTool(NightscoutStatusResult result)
{
if (!result.Configured)
return result.Message;
var lines = new List<string>
{
result.Message,
$"Base URL: {result.BaseUrl}",
$"Fetched: {FormatDateTime(result.FetchedAt)}"
};
if (result.HttpStatus.HasValue)
lines.Add($"HTTP status: {result.HttpStatus}");
if (!string.IsNullOrWhiteSpace(result.Version))
lines.Add($"Nightscout version: {result.Version}");
if (!string.IsNullOrWhiteSpace(result.ServerTime))
lines.Add($"Server time: {result.ServerTime}");
if (result.ApiEnabled.HasValue)
lines.Add($"API enabled: {result.ApiEnabled}");
if (!string.IsNullOrWhiteSpace(result.BodyPreview))
lines.Add($"Body preview: {result.BodyPreview}");
return string.Join("\n", lines);
}
private async Task<NightscoutReadingsResult> FetchReadingsAsync(
string relativeUrl,
int requestedCount,
DateTimeOffset? start,
DateTimeOffset? end,
CancellationToken ct)
{
var result = new NightscoutReadingsResult
{
Configured = IsConfigured(),
FetchedAt = DateTimeOffset.Now,
RequestedCount = requestedCount,
Start = start,
End = end
};
if (!result.Configured)
{
result.Message = "Nightscout is not configured. Set Nightscout URL and API-SECRET in Settings.";
return result;
}
try
{
using var req = BuildRequest(relativeUrl);
result.SourceUrl = req.RequestUri?.ToString() ?? "";
using var res = await _http.SendAsync(req, ct);
result.HttpStatus = (int)res.StatusCode;
var body = await res.Content.ReadAsStringAsync(ct);
if (!res.IsSuccessStatusCode)
{
result.Message = $"Nightscout readings request failed: HTTP {(int)res.StatusCode} {res.ReasonPhrase}. {Trim(body, 700)}";
return result;
}
var entries = JsonSerializer.Deserialize<List<NightscoutEntryDto>>(body, JsonOptions) ?? new();
result.Readings = entries
.Select(ToReading)
.Where(r => r.Timestamp.HasValue && r.Sgv.HasValue)
.OrderByDescending(r => r.Timestamp)
.ToList();
result.Success = true;
result.Message = "Nightscout readings request succeeded.";
result.Summary = BuildSummary(result.Readings);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nightscout readings request failed");
result.Message = $"Nightscout readings request failed: {ex.Message}";
return result;
}
}
private HttpRequestMessage BuildRequest(string relativeUrl)
{
var apiBase = GetApiBaseUrl();
var url = apiBase.TrimEnd('/') + relativeUrl;
var req = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(_config.Nightscout.ApiSecret))
req.Headers.TryAddWithoutValidation("API-SECRET", _config.Nightscout.ApiSecret.Trim());
return req;
}
private bool IsConfigured()
{
return _config.Nightscout.Enabled
&& !string.IsNullOrWhiteSpace(_config.Nightscout.BaseUrl);
}
private string GetApiBaseUrl()
{
var baseUrl = _config.Nightscout.BaseUrl.Trim().TrimEnd('/');
return baseUrl.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase)
? baseUrl
: $"{baseUrl}/api/v1";
}
private static NightscoutReading ToReading(NightscoutEntryDto entry)
{
DateTimeOffset? timestamp = null;
if (entry.Date.HasValue)
{
try
{
timestamp = DateTimeOffset.FromUnixTimeMilliseconds(entry.Date.Value).ToLocalTime();
}
catch
{
timestamp = null;
}
}
if (!timestamp.HasValue && !string.IsNullOrWhiteSpace(entry.DateString)
&& DateTimeOffset.TryParse(entry.DateString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
timestamp = parsed.ToLocalTime();
}
return new NightscoutReading
{
Timestamp = timestamp,
Sgv = entry.Sgv,
Mmol = entry.Sgv.HasValue ? Math.Round(entry.Sgv.Value / 18.0, 1) : null,
Direction = entry.Direction ?? "",
Trend = entry.Trend,
Device = entry.Device ?? "",
Type = entry.Type ?? "",
DateString = entry.DateString ?? ""
};
}
private static NightscoutReadingsSummary? BuildSummary(IReadOnlyList<NightscoutReading> readings)
{
var values = readings
.Where(r => r.Sgv.HasValue)
.Select(r => r.Sgv!.Value)
.ToList();
if (values.Count == 0)
return null;
return new NightscoutReadingsSummary
{
MinSgv = values.Min(),
MaxSgv = values.Max(),
AverageSgv = values.Average()
};
}
private static DateTimeOffset? ParseToolDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return DateTimeOffset.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeLocal,
out var parsed)
? parsed
: null;
}
private static string FormatReading(NightscoutReading reading)
{
var time = reading.Timestamp.HasValue ? FormatDateTime(reading.Timestamp.Value) : "(no timestamp)";
var age = reading.Timestamp.HasValue
? $", age {FormatAge(DateTimeOffset.Now - reading.Timestamp.Value)}"
: "";
var direction = string.IsNullOrWhiteSpace(reading.Direction)
? ""
: $", {reading.Direction} {DirectionArrow(reading.Direction)}".TrimEnd();
var device = string.IsNullOrWhiteSpace(reading.Device) ? "" : $", device {reading.Device}";
return $"{time}: {FormatGlucose(reading.Sgv)}{direction}{age}{device}";
}
private static string FormatGlucose(int? sgv)
{
if (!sgv.HasValue)
return "unknown glucose";
return $"{Math.Round(sgv.Value / 18.0, 1):0.0} mmol/L ({sgv.Value} mg/dL)";
}
private static string FormatGlucose(double sgv)
{
return $"{Math.Round(sgv / 18.0, 1):0.0} mmol/L ({sgv:0} mg/dL)";
}
private static string FormatDateTime(DateTimeOffset value)
{
return value.ToLocalTime().ToString("dddd dd/MM/yyyy hh:mm:ss tt zzz", CultureInfo.InvariantCulture);
}
private static string FormatNullableDateTime(DateTimeOffset? value)
{
return value.HasValue ? FormatDateTime(value.Value) : "(not specified)";
}
private static string FormatAge(TimeSpan age)
{
if (age.TotalMinutes < 1)
return "less than 1 min";
if (age.TotalHours < 1)
return $"{Math.Round(age.TotalMinutes):0} min";
return $"{Math.Round(age.TotalHours, 1):0.0} h";
}
private static string DirectionArrow(string direction) => direction switch
{
"DoubleUp" => "↑↑",
"SingleUp" => "↑",
"FortyFiveUp" => "↗",
"Flat" => "→",
"FortyFiveDown" => "↘",
"SingleDown" => "↓",
"DoubleDown" => "↓↓",
_ => ""
};
private static string? TryGetString(JsonElement root, string name)
{
return root.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()
: null;
}
private static bool? TryGetBool(JsonElement root, string name)
{
return root.TryGetProperty(name, out var prop) && (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
? prop.GetBoolean()
: null;
}
private static string Trim(string text, int max)
{
if (string.IsNullOrWhiteSpace(text))
return "";
text = text.Trim();
return text.Length <= max ? text : text[..max] + "...";
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed class NightscoutEntryDto
{
[JsonPropertyName("sgv")] public int? Sgv { get; set; }
[JsonPropertyName("date")] public long? Date { get; set; }
[JsonPropertyName("dateString")] public string? DateString { get; set; }
[JsonPropertyName("direction")] public string? Direction { get; set; }
[JsonPropertyName("trend")] public int? Trend { get; set; }
[JsonPropertyName("device")] public string? Device { get; set; }
[JsonPropertyName("type")] public string? Type { get; set; }
}
}
public class NightscoutReadingsResult
{
public bool Configured { get; set; }
public bool Success { get; set; }
public string Message { get; set; } = "";
public string SourceUrl { get; set; } = "";
public int? HttpStatus { get; set; }
public int RequestedCount { get; set; }
public DateTimeOffset FetchedAt { get; set; }
public DateTimeOffset? Start { get; set; }
public DateTimeOffset? End { get; set; }
public IReadOnlyList<NightscoutReading> Readings { get; set; } = [];
public NightscoutReadingsSummary? Summary { get; set; }
}
public class NightscoutStatusResult
{
public bool Configured { get; set; }
public bool Success { get; set; }
public string Message { get; set; } = "";
public string BaseUrl { get; set; } = "";
public int? HttpStatus { get; set; }
public DateTimeOffset FetchedAt { get; set; }
public string? Version { get; set; }
public string? ServerTime { get; set; }
public bool? ApiEnabled { get; set; }
public string BodyPreview { get; set; } = "";
}
public class NightscoutReading
{
public DateTimeOffset? Timestamp { get; set; }
public int? Sgv { get; set; }
public double? Mmol { get; set; }
public string Direction { get; set; } = "";
public int? Trend { get; set; }
public string Device { get; set; } = "";
public string Type { get; set; } = "";
public string DateString { get; set; } = "";
}
public class NightscoutReadingsSummary
{
public int MinSgv { get; set; }
public int MaxSgv { get; set; }
public double AverageSgv { get; set; }
}