Newer
Older
SensorReadings / Program.cs
@fabre fabre on 30 Jan 8 KB dotnet
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);

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

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

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

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

    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.ToArray();
    }

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

    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 new[] { crcL, crcH };
    }

    private static ushort U16Le(byte[] data, int offset) =>
        (ushort)(data[offset] | (data[offset + 1] << 8));

    private static short S16Le(byte[] data, int offset) =>
        (short)(data[offset] | (data[offset + 1] << 8));

    private static uint U32Le(byte[] data, int offset) =>
        (uint)(data[offset]
               | (data[offset + 1] << 8)
               | (data[offset + 2] << 16)
               | (data[offset + 3] << 24));

    private static string GetEnv(string name, string defaultValue)
    {
        var value = Environment.GetEnvironmentVariable(name);
        return string.IsNullOrEmpty(value) ? defaultValue : value;
    }

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

    private static bool HasArg(string[] args, string name)
    {
        foreach (var arg in args)
        {
            if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }

        return false;
    }
}