Size: 7.8 KB Modified: 2/07/2026 9:24 PM
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using KaiChat.Models;

namespace KaiChat.Services;

public interface ICgmBridgeService
{
    Task<CgmBridgeSnapshot> GetSnapshotAsync(int requestedCount = 10, CancellationToken ct = default);
    string FormatForPrompt(CgmBridgeSnapshot snapshot);
}

public class CgmBridgeService : ICgmBridgeService
{
    private readonly KaiChatConfig _config;
    private readonly ILogger<CgmBridgeService> _logger;

    public CgmBridgeService(KaiChatConfig config, ILogger<CgmBridgeService> logger)
    {
        _config = config;
        _logger = logger;
    }

    public async Task<CgmBridgeSnapshot> GetSnapshotAsync(int requestedCount = 10, CancellationToken ct = default)
    {
        var ns = _config.Nightscout;
        var snapshot = new CgmBridgeSnapshot
        {
            Enabled = ns.AttachLocalReadingsToMessages,
            Host = string.IsNullOrWhiteSpace(ns.BridgeHost) ? "127.0.0.1" : ns.BridgeHost.Trim(),
            Port = ns.BridgePort > 0 ? ns.BridgePort : 30283,
            FetchedAt = DateTimeOffset.Now
        };

        if (!snapshot.Enabled)
            return snapshot;

        requestedCount = Math.Clamp(requestedCount <= 0 ? 10 : requestedCount, 1, 50);
        var timeoutMs = Math.Clamp(ns.BridgeTimeoutMs <= 0 ? 2500 : ns.BridgeTimeoutMs, 500, 15000);

        try
        {
            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
            timeout.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));

            using var client = new TcpClient();
            await client.ConnectAsync(snapshot.Host, snapshot.Port, timeout.Token);
            await using var stream = client.GetStream();

            var magic = new byte[3];
            await stream.ReadExactlyAsync(magic, timeout.Token);
            var magicText = Encoding.ASCII.GetString(magic);
            if (magicText != "CGM")
            {
                snapshot.Message = $"Unexpected CGM bridge header '{magicText}'.";
                return snapshot;
            }

            var countBuffer = new byte[1];
            await stream.ReadExactlyAsync(countBuffer, timeout.Token);
            var count = countBuffer[0];
            var entriesToRead = Math.Min(count, requestedCount);

            var readings = new List<CgmBridgeReading>();
            for (var i = 0; i < entriesToRead; i++)
            {
                var entry = new byte[13];
                await stream.ReadExactlyAsync(entry, timeout.Token);
                readings.Add(ParseReading(entry));
            }

            snapshot.Success = true;
            snapshot.ReportedCount = count;
            snapshot.Readings = readings
                .Where(r => r.Timestamp.HasValue)
                .OrderByDescending(r => r.Timestamp)
                .ToList();
            snapshot.Message = $"Read {snapshot.Readings.Count} CGM reading(s) from local bridge.";
            return snapshot;
        }
        catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or TimeoutException)
        {
            _logger.LogDebug(ex, "Local CGM bridge snapshot failed");
            snapshot.Message = $"Local CGM bridge unavailable at {snapshot.Host}:{snapshot.Port}: {ex.Message}";
            return snapshot;
        }
    }

    public string FormatForPrompt(CgmBridgeSnapshot snapshot)
    {
        if (!snapshot.Enabled)
            return "";

        var lines = new List<string>
        {
            "=== Live CGM Context (local Nightscout client) ===",
            "Read-only sensor context captured at request time. CGM data can lag or be inaccurate; do not infer insulin dosing, treatment, or urgent medical decisions from this alone.",
            $"Bridge: {snapshot.Host}:{snapshot.Port}",
            $"Fetched: {FormatDateTime(snapshot.FetchedAt)}"
        };

        if (!snapshot.Success)
        {
            lines.Add(snapshot.Message);
            return string.Join("\n", lines);
        }

        lines.Add($"Readings returned: {snapshot.Readings.Count} (bridge reported {snapshot.ReportedCount})");
        lines.Add("Latest readings:");

        foreach (var reading in snapshot.Readings)
            lines.Add($"- {FormatReading(reading, _config.Nightscout.DisplayUnits)}");

        return string.Join("\n", lines);
    }

    private static CgmBridgeReading ParseReading(byte[] entry)
    {
        var sugar = ReadHalf(entry, 0);
        var direction = entry[2];
        var binaryTimestamp = BitConverter.ToInt64(entry, 3);
        var rotationAfter = ReadHalf(entry, 11);

        DateTimeOffset? timestamp = null;
        try
        {
            var dt = DateTime.FromBinary(binaryTimestamp);
            timestamp = dt.Kind == DateTimeKind.Utc
                ? new DateTimeOffset(dt).ToLocalTime()
                : new DateTimeOffset(dt);
        }
        catch
        {
            timestamp = null;
        }

        return new CgmBridgeReading
        {
            SugarLevel = sugar,
            Direction = direction,
            DirectionName = DirectionName(direction),
            Arrow = DirectionArrow(direction),
            Timestamp = timestamp,
            RotationAfter = rotationAfter
        };
    }

    private static string FormatReading(CgmBridgeReading reading, string units)
    {
        var time = reading.Timestamp.HasValue ? FormatDateTime(reading.Timestamp.Value) : "(no timestamp)";
        var age = reading.Timestamp.HasValue
            ? $", age {FormatAge(DateTimeOffset.Now - reading.Timestamp.Value)}"
            : "";
        var displayUnits = string.IsNullOrWhiteSpace(units) ? "mmol/L" : units.Trim();
        var arrow = string.IsNullOrWhiteSpace(reading.Arrow) ? "" : $" {reading.Arrow}";
        return $"{time}: {reading.SugarLevel:0.0} {displayUnits}{arrow}, direction {reading.Direction} ({reading.DirectionName}), arrow rotation {reading.RotationAfter:0.##} degrees{age}";
    }

    private static string DirectionName(byte direction) => direction switch
    {
        1 => "DoubleUp",
        2 => "SingleUp",
        3 => "FortyFiveUp",
        4 => "Flat",
        5 => "FortyFiveDown",
        6 => "SingleDown",
        7 => "DoubleDown",
        8 => "NotComputable",
        9 => "N/A",
        0 => "Unknown",
        _ => "Unknown"
    };

    private static string DirectionArrow(byte direction) => direction switch
    {
        1 => "↑↑",
        2 => "↑",
        3 => "↗",
        4 => "→",
        5 => "↘",
        6 => "↓",
        7 => "↓↓",
        _ => ""
    };

    private static string FormatDateTime(DateTimeOffset value)
    {
        return value.ToLocalTime().ToString("dddd dd/MM/yyyy hh:mm:ss tt zzz", CultureInfo.InvariantCulture);
    }

    private static double ReadHalf(byte[] buffer, int offset)
    {
        return (double)BitConverter.UInt16BitsToHalf(BitConverter.ToUInt16(buffer, offset));
    }

    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";
    }
}

public class CgmBridgeSnapshot
{
    public bool Enabled { get; set; }
    public bool Success { get; set; }
    public string Message { get; set; } = "";
    public string Host { get; set; } = "127.0.0.1";
    public int Port { get; set; } = 30283;
    public int ReportedCount { get; set; }
    public DateTimeOffset FetchedAt { get; set; }
    public IReadOnlyList<CgmBridgeReading> Readings { get; set; } = [];
}

public class CgmBridgeReading
{
    public double SugarLevel { get; set; }
    public byte Direction { get; set; }
    public string DirectionName { get; set; } = "";
    public string Arrow { get; set; } = "";
    public DateTimeOffset? Timestamp { get; set; }
    public double RotationAfter { get; set; }
}
Offline