using System.Globalization;
using System.IO.Ports;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace SensorReadings;
internal sealed class Program
{
private const int DisplayRuleNormallyOff = 0;
private const int DisplayRuleNormallyOn = 1;
private static readonly string Port = GetEnv("SENSOR_PORT", "/dev/ttyUSB0");
private static readonly string Location = GetEnv("SENSOR_LOCATION", "home_2f");
private static readonly string ODataUrl = GetEnv("ODATA_URL", "http://10.16.1.100:8016/odata/SensorReadings");
private static readonly string ODataToken = GetEnv("ODATA_TOKEN", string.Empty).Trim();
private static readonly double TimeoutSeconds = GetEnvDouble("ODATA_TIMEOUT_S", 10);
/// <summary>
/// 程序入口,读取最新传感器数据并可选上报到 OData。
/// </summary>
/// <param name="args">命令行参数。</param>
/// <returns>进程退出码。</returns>
private static int Main(string[] args)
{
try
{
var reading = GetLatestReading(Port, Location, led: true);
if (HasArg(args, "--dry"))
{
var json = JsonSerializer.Serialize(reading, SensorReadingsJsonContext.Default.Jciebu2);
Console.WriteLine(json);
return 0;
}
var (statusCode, body) = PostODataAsync(reading).GetAwaiter().GetResult();
if (statusCode >= 200 && statusCode < 300)
{
Console.WriteLine("ok");
return 0;
}
Console.Error.WriteLine($"http {statusCode}: {body}");
return 2;
}
catch (Exception ex)
{
Console.Error.WriteLine($"error: {ex.Message}");
return 2;
}
}
/// <summary>
/// 将传感器数据发送到配置的 OData 端点。
/// </summary>
/// <param name="payload">要发送的传感器数据。</param>
/// <returns>HTTP 状态码与响应内容。</returns>
private static async Task<(int StatusCode, string Body)> PostODataAsync(Jciebu2 payload)
{
using var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds)
};
var json = JsonSerializer.Serialize(payload, SensorReadingsJsonContext.Default.Jciebu2);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(ODataToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", ODataToken);
}
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var resp = await client.PostAsync(ODataUrl, content).ConfigureAwait(false);
var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
return ((int)resp.StatusCode, body);
}
/// <summary>
/// 从串口设备读取最新数据,并统一时间戳与位置字段。
/// </summary>
/// <param name="port">串口设备路径。</param>
/// <param name="location">位置标识,若为空则使用默认值。</param>
/// <param name="led">是否开启读数时的指示灯。</param>
/// <returns>解析后的传感器数据。</returns>
private static Jciebu2 GetLatestReading(string port, string? location, bool led)
{
using var serial = new SerialPort(port, 115200, Parity.None, 8, StopBits.One)
{
ReadTimeout = 1000,
WriteTimeout = 1000
};
serial.Open();
try
{
if (led)
{
var ledOn = new byte[]
{
0x52, 0x42, 0x0A, 0x00, 0x02, 0x11, 0x51, DisplayRuleNormallyOn, 0x00, 0x00, 0xFF, 0x00
};
WriteWithCrc(serial, ledOn);
Thread.Sleep(100);
ReadAvailable(serial, TimeSpan.FromMilliseconds(300));
}
var latest = new byte[] { 0x52, 0x42, 0x05, 0x00, 0x01, 0x21, 0x50 };
WriteWithCrc(serial, latest);
Thread.Sleep(100);
var data = ReadAvailable(serial, TimeSpan.FromSeconds(1));
var reading = ParseLatestDataLong(data);
var now = DateTimeOffset.UtcNow;
var trimmed = now.AddTicks(-(now.Ticks % TimeSpan.TicksPerSecond));
reading.Ts = trimmed.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);
reading.Location = string.IsNullOrEmpty(location) ? Location : location;
return reading;
}
finally
{
try
{
if (led)
{
var ledOff = new byte[]
{
0x52, 0x42, 0x0A, 0x00, 0x02, 0x11, 0x51, DisplayRuleNormallyOff, 0x00, 0x00, 0x00, 0x00
};
WriteWithCrc(serial, ledOff);
Thread.Sleep(100);
}
}
catch
{
// best effort
}
try
{
if (serial.IsOpen)
{
serial.Close();
}
}
catch
{
// best effort
}
}
}
/// <summary>
/// 将指令附加 CRC 后写入串口。
/// </summary>
/// <param name="serial">串口实例。</param>
/// <param name="command">原始指令字节。</param>
private static void WriteWithCrc(SerialPort serial, byte[] command)
{
var crc = CalcCrc(command);
var buf = new byte[command.Length + crc.Length];
Buffer.BlockCopy(command, 0, buf, 0, command.Length);
Buffer.BlockCopy(crc, 0, buf, command.Length, crc.Length);
serial.Write(buf, 0, buf.Length);
}
/// <summary>
/// 从串口读取可用字节,直到缓冲不再增长或超时。
/// </summary>
/// <param name="serial">串口实例。</param>
/// <param name="timeout">读取超时时间。</param>
/// <returns>读取到的字节数组。</returns>
private static byte[] ReadAvailable(SerialPort serial, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
var buffer = new List<byte>();
var lastLen = -1;
while (DateTime.UtcNow < deadline)
{
var available = serial.BytesToRead;
if (available > 0)
{
var chunk = new byte[available];
var read = serial.Read(chunk, 0, available);
if (read > 0)
{
buffer.AddRange(chunk.Take(read));
}
}
if (buffer.Count == lastLen)
{
break;
}
lastLen = buffer.Count;
Thread.Sleep(50);
}
return [.. buffer];
}
/// <summary>
/// 解析“latest data long”响应为传感器数据。
/// </summary>
/// <param name="data">设备返回的原始数据。</param>
/// <returns>解析后的传感器数据。</returns>
private static Jciebu2 ParseLatestDataLong(byte[] data)
{
if (data.Length < 56)
{
throw new InvalidOperationException($"response too short: {data.Length} bytes (need >= 56)");
}
return new Jciebu2
{
TemperatureC = S16Le(data, 8) / 100.0,
HumidityRh = U16Le(data, 10) / 100.0,
AmbientLight = U16Le(data, 12),
PressureHpa = U32Le(data, 14) / 1000.0,
NoiseDb = U16Le(data, 18) / 100.0,
EtvocPpb = U16Le(data, 20),
Eco2Ppm = U16Le(data, 22),
DiscomfortIndex = U16Le(data, 24) / 100.0,
HeatstrokeIndex = S16Le(data, 26) / 100.0,
VibrationInfo = data[28],
SiValue = U16Le(data, 29) / 10.0,
PgaGal = U16Le(data, 31) / 10.0,
SeismicIntensity = U16Le(data, 33) / 1000.0,
TemperatureFlag = U16Le(data, 35),
HumidityFlag = U16Le(data, 37),
AmbientLightFlag = U16Le(data, 39),
PressureFlag = U16Le(data, 41),
NoiseFlag = U16Le(data, 43),
EtvocFlag = U16Le(data, 45),
Eco2Flag = U16Le(data, 47),
DiscomfortFlag = U16Le(data, 49),
HeatstrokeFlag = U16Le(data, 51),
SiFlag = data[53],
PgaFlag = data[54],
SeismicIntensityFlag = data[55]
};
}
/// <summary>
/// 计算给定缓冲区的 CRC-16(Modbus)。
/// </summary>
/// <param name="buf">输入缓冲区。</param>
/// <returns>CRC 字节(低位在前)。</returns>
private static byte[] CalcCrc(byte[] buf)
{
var crc = 0xFFFF;
for (var i = 0; i < buf.Length; i++)
{
crc ^= buf[i];
for (var bit = 0; bit < 8; bit++)
{
var carry = crc & 1;
crc >>= 1;
if (carry == 1)
{
crc ^= 0xA001;
}
}
}
var crcH = (byte)(crc >> 8);
var crcL = (byte)(crc & 0x00FF);
return [crcL, crcH];
}
/// <summary>
/// 从缓冲区读取无符号 16 位小端值。
/// </summary>
/// <param name="data">数据缓冲区。</param>
/// <param name="offset">起始偏移。</param>
/// <returns>读取到的数值。</returns>
private static ushort U16Le(byte[] data, int offset) =>
(ushort)(data[offset] | (data[offset + 1] << 8));
/// <summary>
/// 从缓冲区读取有符号 16 位小端值。
/// </summary>
/// <param name="data">数据缓冲区。</param>
/// <param name="offset">起始偏移。</param>
/// <returns>读取到的数值。</returns>
private static short S16Le(byte[] data, int offset) =>
(short)(data[offset] | (data[offset + 1] << 8));
/// <summary>
/// 从缓冲区读取无符号 32 位小端值。
/// </summary>
/// <param name="data">数据缓冲区。</param>
/// <param name="offset">起始偏移。</param>
/// <returns>读取到的数值。</returns>
private static uint U32Le(byte[] data, int offset) =>
(uint)(data[offset]
| (data[offset + 1] << 8)
| (data[offset + 2] << 16)
| (data[offset + 3] << 24));
/// <summary>
/// 读取环境变量,未设置或为空时返回默认值。
/// </summary>
/// <param name="name">环境变量名。</param>
/// <param name="defaultValue">默认值。</param>
/// <returns>环境变量值或默认值。</returns>
private static string GetEnv(string name, string defaultValue)
{
var value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrEmpty(value) ? defaultValue : value;
}
/// <summary>
/// 读取环境变量为 double(InvariantCulture),失败则返回默认值。
/// </summary>
/// <param name="name">环境变量名。</param>
/// <param name="defaultValue">默认值。</param>
/// <returns>解析结果或默认值。</returns>
private static double GetEnvDouble(string name, double defaultValue)
{
var value = Environment.GetEnvironmentVariable(name);
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: defaultValue;
}
/// <summary>
/// 判断参数列表中是否包含指定开关。
/// </summary>
/// <param name="args">命令行参数。</param>
/// <param name="name">要查找的参数名。</param>
/// <returns>存在则为 true。</returns>
private static bool HasArg(string[] args, string name)
{
foreach (var arg in args)
{
if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}