Newer
Older
SensorReadings / Program.cs
@fabre fabre on 1 Feb 11 KB 骚一下更健康
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;
    }
}