← Back to Files
CgmBridgeService.cs
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; }
}