// ===================================================================== // WMS Weight Agent - console/service app for the weighbridge PC. // // Responsibilities: // 1. Read the weighing indicator over the USB-RS232 COM port. // 2. Parse the raw string into a numeric weight (port your exact parsing // rules from the existing WinForms clsSerialPort.cs - start char, // length, decimal point handling, reverse-string flag, etc. The // ParseWeight() stub below is intentionally simple; replace its body // with your real logic since indicator string formats vary by model). // 3. POST the latest reading to the cloud every PostIntervalMs. // 4. Serve the same reading on http://localhost:{LocalHttpPort}/weight // for the operator's own browser (works even if internet is down). // // Run as a normal console app for testing, or wrap with a tool like // NSSM / sc.exe to install as a Windows Service for unattended startup. // ===================================================================== using System; using System.Collections.Generic; using System.Configuration; using System.Diagnostics; using System.IO; using System.IO.Ports; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace WeightAgentService { class Program { static readonly List _channels = new List(); static Channel Primary { get { return _channels[0]; } } static readonly HttpClient _http = new HttpClient(); // Cable unplugged / indicator off: the last reading must NOT keep being sent. A reading is only "live" while valid // frames keep arriving; after StaleDataTimeoutMs of silence the agent reports "no signal" instead of the old weight. // Indicator protocol: built-in default (the format this agent always read), replaced by whatever is set for this weighbridge // on the cloud panel (Indicator Settings). Fetched every few seconds by the sync loop. const string AgentVersion = "1.4.0"; static volatile bool _liveWanted = false; // someone is watching a live camera view (camera part) static int StaleDataTimeoutMs = 3000; // 0 = never treat data as stale (only for indicators that send on change) static int PortReopenAfterSilenceMs = 15000; // port open but silent this long -> close and reopen it (cable/adapter replug) static int PortRetryIntervalMs = 2000; static string ComPort, DeviceId, DeviceKey, CloudEndpoint; static int BaudRate, PostIntervalMs, LocalHttpPort; static bool CameraCaptureEnabled; static string CloudBaseUrl, FfmpegPath; static int CameraPollIntervalMs; static Mutex _singleInstance; // true = we are the only agent on this PC. (A named Mutex is machine-wide on Windows; the automatic copy runs as SYSTEM, so a // normal user's copy may not even be allowed to open it - that also means "already running".) internal static bool AcquireSingleInstance(string name = "Global\\WMSWeightAgent") { bool first; try { _singleInstance = new Mutex(true, name, out first); } catch (UnauthorizedAccessException) { return false; } return first; } static void Main(string[] args) { // One agent per PC. A second copy (for example double-clicked while the automatic one is running) would fight over the COM port. if (!AcquireSingleInstance()) { Console.WriteLine("The WMS Weight Agent is already running on this PC (it starts by itself with Windows). Nothing to do."); if (Environment.UserInteractive) Thread.Sleep(4000); return; } InstallFileLog(); // started by Windows there is no window to read - everything is also written to logs\agent_yyyyMMdd.log next to the exe AppDomain.CurrentDomain.UnhandledException += (sender, e) => Console.WriteLine($"[fatal] {e.ExceptionObject}"); Console.WriteLine($"=== agent {AgentVersion} starting (Windows user: {Environment.UserName}) ==="); LoadConfig(); foreach (var ch in _channels) ch.Start(); // per weighbridge: COM port, indicator format sync, live weight to the cloud StartLocalHttpServer(); if (CameraCaptureEnabled) { StartCameraCaptureLoop(); StartLiveFeedLoop(); } Console.WriteLine($"WMS Weight Agent running for {(_channels.Count > 1 ? "weighbridges " : "device ")}{string.Join(", ", _channels.Select(c => "'" + c.DeviceId + "' (" + c.ComPort + ")"))}. Press Ctrl+C to stop."); Console.WriteLine($" Local: http://localhost:{LocalHttpPort}/weight (all weighbridges: /weights)"); Console.WriteLine($" Cloud: {CloudEndpoint}"); if (CameraCaptureEnabled) Console.WriteLine($" Camera: ffmpeg = {FfmpegPath} ({(File.Exists(FfmpegPath) ? "found" : "NOT found on disk - will try the PATH")})"); else Console.WriteLine(" Camera: CameraCaptureEnabled is not true in App.config - photos and Live Cameras are off"); Thread.Sleep(Timeout.Infinite); } static void LoadConfig() { var s = ConfigurationManager.AppSettings; ComPort = s["ComPort"]; BaudRate = int.Parse(s["BaudRate"]); DeviceId = s["DeviceId"]; DeviceKey = s["DeviceKey"]; CloudEndpoint = s["CloudEndpoint"]; PostIntervalMs = int.Parse(s["PostIntervalMs"]); LocalHttpPort = int.Parse(s["LocalHttpPort"]); CameraCaptureEnabled = bool.TryParse(s["CameraCaptureEnabled"], out var cce) && cce; CloudBaseUrl = s["CloudBaseUrl"]; FfmpegPath = ResolveFfmpeg(s["FfmpegPath"]); CameraPollIntervalMs = int.TryParse(s["CameraPollIntervalMs"], out var cpi) ? cpi : 2000; StaleDataTimeoutMs = int.TryParse(s["StaleDataTimeoutMs"], out var sto) ? sto : 3000; PortReopenAfterSilenceMs = int.TryParse(s["PortReopenAfterSilenceMs"], out var pro) ? pro : 15000; LoadChannels(s); } // One weighbridge (the classic keys DeviceId / DeviceKey / ComPort) or up to four: Weighbridge1.DeviceId, Weighbridge1.DeviceKey, // Weighbridge1.ComPort, Weighbridge1.BaudRate (optional), Weighbridge2.* ... The first one is the primary. internal static void LoadChannels(System.Collections.Specialized.NameValueCollection s) { _channels.Clear(); if (!string.IsNullOrWhiteSpace(s["Weighbridge1.DeviceId"])) { for (int i = 1; i <= 4; i++) { string id = (s["Weighbridge" + i + ".DeviceId"] ?? "").Trim(); if (id == "") continue; string key = (s["Weighbridge" + i + ".DeviceKey"] ?? "").Trim(), com = (s["Weighbridge" + i + ".ComPort"] ?? "").Trim(); if (key == "") throw new InvalidOperationException("App.config: Weighbridge" + i + ".DeviceKey is missing."); if (_channels.Any(c => c.DeviceId == id)) throw new InvalidOperationException("App.config: the device id '" + id + "' is used twice."); int baud; if (!int.TryParse(s["Weighbridge" + i + ".BaudRate"], out baud)) baud = BaudRate; _channels.Add(new Channel(id, key, com == "" ? ComPort : com, baud, _channels.Count == 0)); } var dup = _channels.GroupBy(c => (c.ComPort ?? "").ToUpperInvariant()).FirstOrDefault(g => g.Key != "" && g.Count() > 1); if (dup != null) Console.WriteLine("[config] WARNING: " + dup.Key + " is set for two weighbridges - each indicator needs its own COM port (Indicator Settings on the cloud can also set it)."); } else _channels.Add(new Channel(DeviceId, DeviceKey, ComPort, BaudRate, true)); // the shared (camera) jobs talk to the cloud as the first weighbridge DeviceId = Primary.DeviceId; DeviceKey = Primary.DeviceKey; ComPort = Primary.ComPort; } // ffmpeg: the path in App.config if it exists, else an ffmpeg.exe placed next to the agent, else whatever is on the PATH. static string ResolveFfmpeg(string configured) { string local = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ffmpeg.exe"); if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) return configured; if (File.Exists(local)) return local; return string.IsNullOrWhiteSpace(configured) ? "ffmpeg.exe" : configured; } // ---------------- Signal freshness (testable on its own) ---------------- // ===================================================================== // One weighbridge = one Channel: its own COM port, reading, indicator format and cloud connection (device id + key). // The agent runs one Channel per weighbridge configured on this PC (one, or two for a dual weighbridge). // The first one is the "primary": it also does the shared jobs (cameras: photos, live view) for the site. // ===================================================================== internal sealed class Channel { public readonly string DeviceId, DeviceKey, ComPort; public readonly int BaudRate; public readonly bool IsPrimary; SerialPort _port; internal volatile string _lastWeight = "0"; internal volatile bool _lastStable = false; internal volatile bool _connected = false; internal volatile IndicatorProtocol _proto = IndicatorProtocol.Default(); int _appliedVersion = 0; string _configError = ""; readonly StringBuilder _rawTail = new StringBuilder(); // the last ~1500 characters received, shown on the cloud panel volatile bool _reportSoon = false; internal SignalMonitor _signal; public Channel(string deviceId, string deviceKey, string comPort, int baudRate, bool isPrimary) { DeviceId = deviceId; DeviceKey = deviceKey; ComPort = comPort; BaudRate = baudRate; IsPrimary = isPrimary; _signal = new SignalMonitor(TimeSpan.FromMilliseconds(StaleDataTimeoutMs)); } // with two weighbridges every log line says which one it is about string Prefix { get { return _channels.Count > 1 ? "[" + DeviceId + "] " : ""; } } void Log(string message) { Console.WriteLine(Prefix + message); } internal void Start() { StartSerialLoop(); StartSyncLoop(); StartCloudPostLoop(); } // ---------------- Serial read ---------------- readonly StringBuilder _rxBuffer = new StringBuilder(); // Everything that arrives from the indicator goes through here (also used by the tests): keep the raw text for the cloud panel, // cut it into readings with the current protocol, remember the newest one. void OnSerialText(string raw) { AppendRawTail(raw); _rxBuffer.Append(raw); if (_proto.Extract(_rxBuffer, out string weight, out bool stable)) { _lastWeight = weight; _lastStable = stable; _signal.MarkData(); } else if (_rxBuffer.Length > 1500) { Log("[parse] nothing matches the protocol in the last 1500 characters - check Indicator Settings (format / baud rate)"); _rxBuffer.Clear(); } } void ClosePort() { _connected = false; try { if (_port != null) { if (_port.IsOpen) _port.Close(); _port.Dispose(); } } catch { /* already gone */ } _port = null; _rxBuffer.Clear(); } string CurrentPortName() { var p = _proto; return !string.IsNullOrEmpty(p.com_port) ? p.com_port : ComPort; } bool TryOpenPort() { var p = _proto; string name = CurrentPortName(); try { ClosePort(); int baud = p.baud_rate > 0 ? p.baud_rate : BaudRate; _port = new SerialPort(name, baud, ParseEnum(p.parity, Parity.None), p.data_bits > 0 ? p.data_bits : 8, ParseEnum(p.stop_bits, StopBits.One)); _port.Handshake = ParseEnum(p.flow_control, Handshake.None); _port.Encoding = Encoding.GetEncoding("iso-8859-1"); // one byte = one character: bytes above 127 (parity bit, binary indicators) are kept, not turned into '?', so the hex view shows the truth _port.Open(); _port.DiscardInBuffer(); _connected = true; Log($"[serial] opened {name} @ {baud}bps {p.data_bits}{(p.parity ?? "None").Substring(0, 1)}{(p.stop_bits == "Two" ? "2" : "1")}"); return true; } catch (Exception ex) { Log($"[serial] cannot open {name}: {ex.Message} (will retry)"); ClosePort(); return false; } } // ONE loop does everything: (re)open the port, read, and recover. It polls instead of using the DataReceived event // (that event can silently stop on sustained streams). It never gives up: a removed USB adapter, a pulled RS232 cable // or a hung port just means "no signal" until data flows again - no restart of the program is ever needed. void StartSerialLoop() { Task.Run(async () => { int tick = 0; var lastOpenAttempt = DateTime.MinValue; var portOpenedAt = DateTime.MinValue; bool wasFresh = false; while (true) { try { if (_port == null || !_port.IsOpen) { _connected = false; if ((DateTime.UtcNow - lastOpenAttempt).TotalMilliseconds >= PortRetryIntervalMs) { lastOpenAttempt = DateTime.UtcNow; if (TryOpenPort()) portOpenedAt = DateTime.UtcNow; } } else if (_port.BytesToRead > 0) { OnSerialText(_port.ReadExisting()); } else if (PortReopenAfterSilenceMs > 0 && _signal.SilenceFor.TotalMilliseconds > PortReopenAfterSilenceMs && (DateTime.UtcNow - portOpenedAt).TotalMilliseconds > PortReopenAfterSilenceMs) { Log($"[serial] no data for {PortReopenAfterSilenceMs / 1000}s - closing and reopening {ComPort}"); ClosePort(); // the loop reopens it on the next retry portOpenedAt = DateTime.UtcNow; } bool fresh = _signal.IsFresh; if (fresh != wasFresh) { wasFresh = fresh; Log(fresh ? $"[SIGNAL OK] receiving data again - weight={_lastWeight}" : $"[NO SIGNAL] no valid data for {StaleDataTimeoutMs / 1000.0:0.#}s - the last reading is no longer sent"); } tick++; if (tick % 25 == 0) // ~every 5 s Log($"[heartbeat] port={(_connected ? "open" : "CLOSED")} signal={(fresh ? "OK" : "NONE")} lastWeight={_lastWeight} stable={_lastStable}"); } catch (Exception ex) { Log($"[serial] read error: {ex.Message} - closing the port, will reopen"); ClosePort(); // e.g. the USB adapter was unplugged: the old handle is dead } await Task.Delay(Math.Max(50, _proto.refresh_ms)); } }); } // ---------------- Cloud push ---------------- void StartCloudPostLoop() { Task.Run(async () => { while (true) { bool fresh = _signal.IsFresh; try { // Fresh signal -> the live reading. No signal -> an explicit "no_signal" so the web app shows Offline at once // (instead of re-sending a stale weight that would keep the cloud believing the scale is alive). var payload = JsonSerializer.Serialize(BuildCloudPayload(DeviceId, fresh, _lastWeight, _lastStable)); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var req = new HttpRequestMessage(HttpMethod.Post, CloudEndpoint) { Content = content }; req.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(req); if (!resp.IsSuccessStatusCode) Log($"[post] REJECTED {(int)resp.StatusCode}"); else if (fresh) Log($"[post] ok - sent weight={_lastWeight} stable={_lastStable}"); } catch (Exception ex) { // No internet / cloud down - just log and retry on the next tick. Never let this crash the agent. Log($"Cloud post failed (will retry): {ex.Message}"); } await Task.Delay(fresh ? PostIntervalMs : Math.Max(PostIntervalMs, 2000)); } }); } void AppendRawTail(string raw) { lock (_rawTail) { _rawTail.Append(raw); if (_rawTail.Length > 1500) _rawTail.Remove(0, _rawTail.Length - 1500); } } // Installs the protocol the cloud sent. A bad pattern never replaces a working one - the error goes back to the panel. void ApplyProtocol(IndicatorProtocol p, int version) { try { p.Prepare(); var old = _proto; bool serialChanged = !old.SameSerial(p, ComPort, BaudRate); _proto = p; _appliedVersion = version; _configError = ""; _reportSoon = true; Log($"[config] protocol v{version} applied{(string.IsNullOrEmpty(p.name) ? "" : " - " + p.name)}"); if (serialChanged) { Log("[config] serial settings changed - reopening the port"); ClosePort(); } } catch (Exception ex) { _configError = ("v" + version + ": " + ex.Message); if (_configError.Length > 200) _configError = _configError.Substring(0, 200); _reportSoon = true; Log($"[config] protocol v{version} NOT applied: {ex.Message}"); } } // Every few seconds: tell the cloud what we receive (raw text, port state, applied version) and fetch this weighbridge's protocol. void StartSyncLoop() { Task.Run(async () => { while (true) { try { string tail; lock (_rawTail) { tail = _rawTail.ToString(); } var dict = new Dictionary { ["device_id"] = DeviceId, ["applied_version"] = _appliedVersion, ["config_error"] = _configError, ["port_state"] = _connected ? "open" : "closed", ["signal"] = _signal.IsFresh, ["agent_version"] = AgentVersion, ["last_weight"] = _lastWeight, ["raw_tail"] = tail, }; if (IsPrimary) dict["camera_status"] = CameraStatusReport(); // the camera work belongs to the first weighbridge of this PC only var payload = JsonSerializer.Serialize(dict); var req = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/agent_sync.php") { Content = new StringContent(payload, Encoding.UTF8, "application/json") }; req.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(req); if (resp.IsSuccessStatusCode) { var sync = JsonSerializer.Deserialize(await resp.Content.ReadAsStringAsync()); if (sync != null) { if (IsPrimary) await UpdateLiveFeeds(sync.live_cameras); // the camera work belongs to the first weighbridge of this PC only if (sync.version != _appliedVersion || (sync.version == 0 && _appliedVersion != 0)) { if (sync.config == null) { if (_appliedVersion != 0) { _proto = IndicatorProtocol.Default(); _appliedVersion = 0; _configError = ""; _reportSoon = true; Log("[config] cloud setting removed - back to the built-in default"); ClosePort(); } } else ApplyProtocol(sync.config, sync.version); } } } } catch (Exception ex) { Log($"[sync] {ex.Message}"); } // a protocol was just applied (or rejected): report that straight away so the panel shows the true state await Task.Delay(_reportSoon ? 400 : (_liveWanted ? 2000 : 3000)); _reportSoon = false; } }); } } internal sealed class SignalMonitor { readonly TimeSpan _timeout; readonly Func _now; DateTime _lastData = DateTime.MinValue; public SignalMonitor(TimeSpan timeout, Func now = null) { _timeout = timeout; _now = now ?? (() => DateTime.UtcNow); } public void MarkData() { _lastData = _now(); } // Fresh = a valid frame arrived within the timeout. A zero timeout disables the check (always fresh once data was ever seen). public bool IsFresh => _lastData != DateTime.MinValue && (_timeout <= TimeSpan.Zero || (_now() - _lastData) <= _timeout); public TimeSpan SilenceFor => _lastData == DateTime.MinValue ? TimeSpan.MaxValue : _now() - _lastData; } // What the cloud is told: the live reading while the signal is fresh, otherwise an explicit "no signal". internal static Dictionary BuildCloudPayload(string deviceId, bool fresh, string weight, bool stable) { var d = new Dictionary { ["device_id"] = deviceId }; if (fresh) { d["weight"] = weight; d["stable"] = stable; } else d["no_signal"] = true; return d; } static T ParseEnum(string text, T fallback) where T : struct { T v; return Enum.TryParse(text ?? "", true, out v) ? v : fallback; } // Real format captured from the actual indicator on COM12: // ST,GS,+ 3890kg // US,GS,+ 3890kg (when the reading is unstable/in motion) // ST/US = stable/unstable, GS = gross mode, sign+padded digits, then "kg". // No decimal point on this indicator - it reports whole kg only. static readonly System.Text.RegularExpressions.Regex WeightPattern = new System.Text.RegularExpressions.Regex(@"(ST|US),(GS|NT),([+-])\s*(\d+)kg"); static bool TryParseWeight(string raw, out string weight, out bool stable) { weight = null; stable = false; if (string.IsNullOrWhiteSpace(raw)) return false; return IndicatorProtocol.Default().Extract(new StringBuilder(raw), out weight, out stable); } // ---------------- Local HTTP for the operator's own browser ---------------- // /weight the reading (as always) /live real-time camera page, for the browser ON THIS PC // /cameras cameras + their status (json) /camera/N.mjpg one camera as a real-time video stream (about 10 pictures/second) // The streams go straight from the camera to this browser: no internet, no cloud, no waiting. static void StartLocalHttpServer() { var listener = new HttpListener(); listener.Prefixes.Add($"http://localhost:{LocalHttpPort}/"); listener.Start(); Task.Run(async () => { while (true) { HttpListenerContext ctx; try { ctx = await listener.GetContextAsync(); } catch { continue; } var c = ctx; _ = Task.Run(() => HandleLocalRequest(c)); // every request on its own task: a video stream must never hold up the weight reading } }); } static async Task LocalSend(HttpListenerContext ctx, int code, string contentType, string body) { try { var buf = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = code; ctx.Response.ContentType = contentType; ctx.Response.Headers.Add("Access-Control-Allow-Origin", "*"); ctx.Response.Headers.Add("Cache-Control", "no-store"); await ctx.Response.OutputStream.WriteAsync(buf, 0, buf.Length); } catch { } finally { try { ctx.Response.Close(); } catch { } } } static async Task HandleLocalRequest(HttpListenerContext ctx) { try { string path = ctx.Request.Url.AbsolutePath.TrimEnd('/'); var m = Regex.Match(path, @"^/camera/(\d+)\.mjpg$"); if (m.Success) { await StreamCameraMjpeg(ctx, int.Parse(m.Groups[1].Value)); return; } if (path == "/live") { await LocalSend(ctx, 200, "text/html; charset=utf-8", LivePageHtml); return; } if (path == "/cameras") { var cams = await GetCamerasCached(); var list = cams.Select(c => new Dictionary { ["id"] = c.camera_id, ["name"] = c.camera_name, ["position"] = c.position ?? "", ["status"] = HealthStatus(c.camera_id), ["message"] = !string.IsNullOrEmpty(c.problem) ? CameraMessage(c.problem, c, "") : HealthMessage(c.camera_id) }).ToList(); await LocalSend(ctx, 200, "application/json", JsonSerializer.Serialize(list)); return; } if (path == "/weights") // every weighbridge of this PC { await LocalSend(ctx, 200, "application/json", JsonSerializer.Serialize(_channels.Select(c => WeightInfo(c)).ToList())); return; } await LocalSend(ctx, 200, "application/json", JsonSerializer.Serialize(WeightInfo(Primary))); // anything else: the first weighbridge's reading, exactly as before } catch (Exception ex) { Console.WriteLine($"[local] {ex.Message}"); try { ctx.Response.Abort(); } catch { } } } static object WeightInfo(Channel c) { bool fresh = c._signal.IsFresh; return new { device_id = c.DeviceId, weight = fresh ? c._lastWeight : null, stable = fresh && c._lastStable, connected = c._connected, signal = fresh }; // never show an old reading as if it were live } static async Task> GetCamerasCached() { if (_camCache.Count == 0 || DateTime.UtcNow - _camCacheAt > TimeSpan.FromSeconds(20)) { try { var fresh = await FetchCameraList(); if (fresh.Count > 0 || _camCache.Count == 0) _camCache = fresh; // a failed download must not wipe a good list _camCacheAt = DateTime.UtcNow; } catch (Exception ex) { Console.WriteLine($"[camera] camera list: {ex.Message}"); } } return _camCache; } static readonly Dictionary _mjpeg = new Dictionary(); // One camera as a real-time MJPEG video (the browser shows it in a plain ). The pictures are cut from the camera's stream by ffmpeg // and passed on at once. A newer viewer of the same camera replaces the older one, so a camera never gets more than one such connection. static async Task StreamCameraMjpeg(HttpListenerContext ctx, int cameraId) { var cams = await GetCamerasCached(); var cam = cams.FirstOrDefault(c => c.camera_id == cameraId); if (cam == null) { await LocalSend(ctx, 404, "application/json", "{\"error\":\"unknown camera\"}"); return; } string why; if (!CanTry(cam, out why)) { string msg = !string.IsNullOrEmpty(cam.problem) ? CameraMessage(cam.problem, cam, "") : HealthMessage(cameraId); await LocalSend(ctx, 503, "application/json", JsonSerializer.Serialize(new Dictionary { ["status"] = HealthStatus(cameraId), ["message"] = msg })); return; } var cts = new CancellationTokenSource(); lock (_mjpeg) { CancellationTokenSource old; if (_mjpeg.TryGetValue(cameraId, out old)) { try { old.Cancel(); } catch { } } _mjpeg[cameraId] = cts; } Process proc = null; try { // 10 pictures a second, 640 px wide: smooth, and light enough for the laptop and the browser string args = $"-hide_banner -loglevel error -fflags nobuffer -flags low_delay -probesize 100000 -analyzeduration 500000 -rtsp_transport tcp -i \"{RtspUrl(cam)}\" -an -vf \"fps=10,scale=640:-2\" -q:v 5 -f mpjpeg -boundary_tag frame pipe:1"; var psi = new ProcessStartInfo { FileName = FfmpegPath, Arguments = args, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; proc = Process.Start(psi); var err = new StringBuilder(); proc.ErrorDataReceived += (s, e) => { if (e.Data != null) lock (err) err.AppendLine(Mask(e.Data)); }; proc.BeginErrorReadLine(); var killer = proc; cts.Token.Register(() => { try { killer.Kill(); } catch { } }); // a replaced or gone viewer stops its ffmpeg var stdout = proc.StandardOutput.BaseStream; var buf = new byte[65536]; var firstRead = stdout.ReadAsync(buf, 0, buf.Length); if (await Task.WhenAny(firstRead, Task.Delay(15000)) != firstRead) // the camera did not answer in 15 s { SetHealth(cam, "unreachable"); await LocalSend(ctx, 503, "application/json", JsonSerializer.Serialize(new Dictionary { ["status"] = "unreachable", ["message"] = HealthMessage(cameraId) })); return; } int n = 0; try { n = firstRead.Result; } catch { } if (n <= 0) // ffmpeg ended without a picture: find out why { await Task.Delay(300); string e; lock (err) { e = err.ToString(); } string st = ClassifyCameraError(e); SetHealth(cam, st, Brief(e)); await LocalSend(ctx, 503, "application/json", JsonSerializer.Serialize(new Dictionary { ["status"] = st, ["message"] = HealthMessage(cameraId) })); return; } SetHealth(cam, "ok"); ctx.Response.StatusCode = 200; ctx.Response.ContentType = "multipart/x-mixed-replace;boundary=frame"; ctx.Response.SendChunked = true; ctx.Response.Headers.Add("Cache-Control", "no-store"); ctx.Response.Headers.Add("Access-Control-Allow-Origin", "*"); var output = ctx.Response.OutputStream; await output.WriteAsync(buf, 0, n); while (!cts.IsCancellationRequested) { n = await stdout.ReadAsync(buf, 0, buf.Length); if (n <= 0) break; await output.WriteAsync(buf, 0, n); // throws when the browser has gone: the loop ends } } catch { /* the viewer left or ffmpeg ended */ } finally { try { if (proc != null && !proc.HasExited) proc.Kill(); } catch { } try { proc?.Dispose(); } catch { } lock (_mjpeg) { CancellationTokenSource cur; if (_mjpeg.TryGetValue(cameraId, out cur) && cur == cts) _mjpeg.Remove(cameraId); } try { ctx.Response.Close(); } catch { } } } // the page opened at http://localhost:/live on the weighbridge PC const string LivePageHtml = @"Real-time cameras

Real-time cameras (only on this PC)

"; // ===================================================================== // Log file: everything the agent prints is also written to logs\agent_yyyyMMdd.log (one file a day, 14 days kept). // ===================================================================== internal sealed class TeeWriter : TextWriter { readonly TextWriter _inner; readonly string _dir; readonly int _keepDays; readonly object _lock = new object(); StreamWriter _file; string _fileDay; public TeeWriter(TextWriter inner, string dir, int keepDays = 14) { _inner = inner; _dir = dir; _keepDays = keepDays; } public override Encoding Encoding { get { return Encoding.UTF8; } } public override void Write(char value) { try { _inner.Write(value); } catch { } ToFile(value.ToString(), false); } public override void Write(string value) { try { _inner.Write(value); } catch { } ToFile(value, false); } public override void WriteLine(string value) { try { _inner.WriteLine(value); } catch { } ToFile(value + "\r\n", true); } public override void Flush() { try { _inner.Flush(); } catch { } } void ToFile(string text, bool stamp) { lock (_lock) { try { string day = DateTime.Now.ToString("yyyyMMdd"); if (_file == null || day != _fileDay) { if (_file != null) _file.Dispose(); Directory.CreateDirectory(_dir); _file = new StreamWriter(new FileStream(Path.Combine(_dir, "agent_" + day + ".log"), FileMode.Append, FileAccess.Write, FileShare.ReadWrite), new UTF8Encoding(false)); _fileDay = day; DeleteOldLogs(); } _file.Write((stamp ? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss ") : "") + text); _file.Flush(); } catch { /* a log problem must never stop the agent */ } } } void DeleteOldLogs() { try { foreach (var f in Directory.GetFiles(_dir, "agent_*.log")) if (File.GetLastWriteTime(f) < DateTime.Now.AddDays(-_keepDays)) File.Delete(f); } catch { } } } static void InstallFileLog() { try { Console.SetOut(new TeeWriter(Console.Out, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"))); } catch { } } // ===================================================================== // Indicator protocol engine - the SAME rules as public/assets/js/indicator_parse.js (the live preview on the cloud panel). // ===================================================================== internal sealed class IndicatorProtocol { public string name { get; set; } = ""; public string com_port { get; set; } = ""; public int baud_rate { get; set; } = 9600; public int data_bits { get; set; } = 8; public string parity { get; set; } = "None"; public string stop_bits { get; set; } = "One"; public string flow_control { get; set; } = "None"; public string frame_mode { get; set; } = "auto"; // auto | terminator | length public string terminator { get; set; } = "CRLF"; // CRLF | LF | CR | ETX | CUSTOM public string terminator_hex { get; set; } = ""; public int frame_length { get; set; } = 18; public string method { get; set; } = "regex"; // regex | position public string regex { get; set; } = ""; public string stable_text { get; set; } = "ST"; public string start_value { get; set; } = "ST"; public string unstable_value { get; set; } = "US"; public int start_index { get; set; } = 6; public int digits { get; set; } = 8; public bool reverse { get; set; } = false; public int decimals { get; set; } = 0; public int refresh_ms { get; set; } = 200; Regex _re; // The format this agent has always read: ST,GS,+ 460kg public static IndicatorProtocol Default() { var p = new IndicatorProtocol { name = "built-in default", regex = @"(?ST|US),(?:GS|NT),(?[+-])\s*(?\d+(?:\.\d+)?)\s*kg" }; p.Prepare(); return p; } // compile the pattern (throws if it is invalid - the caller keeps the previous protocol and reports the error) public void Prepare() { if (method == "regex") { _re = new Regex(regex ?? ""); if (_re.GetGroupNumbers().Length < 2 || Array.IndexOf(_re.GetGroupNames(), "weight") < 0) throw new ArgumentException("the pattern needs a group named weight"); } if (method == "position" && frame_mode == "auto") throw new ArgumentException("fixed positions need a terminator or a fixed length"); } // serial settings changed -> the port must be reopened public bool SameSerial(IndicatorProtocol o, string defaultPort, int defaultBaud) => (string.IsNullOrEmpty(com_port) ? defaultPort : com_port) == (string.IsNullOrEmpty(o.com_port) ? defaultPort : o.com_port) && (baud_rate > 0 ? baud_rate : defaultBaud) == (o.baud_rate > 0 ? o.baud_rate : defaultBaud) && data_bits == o.data_bits && parity == o.parity && stop_bits == o.stop_bits && flow_control == o.flow_control; string TerminatorText() { switch (terminator) { case "LF": return "\n"; case "CR": return "\r"; case "ETX": return "\u0003"; case "CUSTOM": var sb = new StringBuilder(); string h = terminator_hex ?? ""; for (int i = 0; i + 1 < h.Length; i += 2) sb.Append((char)Convert.ToInt32(h.Substring(i, 2), 16)); return sb.ToString(); default: return "\r\n"; } } // drop a leading STX and trailing ETX / CR / LF static string Clean(string f) => f.TrimStart('\u0002').TrimEnd('\u0003', '\r', '\n'); // implied decimals, strip leading zeros, sign internal static string Format(string sign, string num, int decimals) { if (num.IndexOf('.') < 0 && decimals > 0) { while (num.Length < decimals + 1) num = "0" + num; num = num.Substring(0, num.Length - decimals) + "." + num.Substring(num.Length - decimals); } var parts = num.Split('.'); string ip = Regex.Replace(parts[0], "^0+(?=\\d)", ""); string outv = parts.Length > 1 ? ip + "." + parts[1] : ip; bool zero = Regex.IsMatch(outv, "^0*\\.?0*$"); return (sign == "-" && !zero ? "-" : "") + outv; } public bool TryParseFrame(string frame, out string weight, out bool stable) { weight = null; stable = false; string f = Clean(frame); if (f.Length == 0) return false; if (method == "position") { stable = true; if (!string.IsNullOrEmpty(start_value)) { if (f.StartsWith(start_value, StringComparison.Ordinal)) stable = true; else if (!string.IsNullOrEmpty(unstable_value) && f.StartsWith(unstable_value, StringComparison.Ordinal)) stable = false; else return false; } if (f.Length < start_index + digits) return false; string sub = f.Substring(start_index, digits); if (reverse) { var arr = sub.ToCharArray(); Array.Reverse(arr); sub = new string(arr); } var pm = Regex.Match(sub, @"^\s*([+-]?)\s*(\d+(?:\.\d+)?)"); if (!pm.Success) return false; weight = Format(pm.Groups[1].Value, pm.Groups[2].Value, decimals); return true; } var m = _re.Match(f); if (!m.Success || !m.Groups["weight"].Success) return false; stable = !m.Groups["stable"].Success || m.Groups["stable"].Value == (string.IsNullOrEmpty(stable_text) ? "ST" : stable_text); weight = Format(m.Groups["sign"].Success ? m.Groups["sign"].Value : "", m.Groups["weight"].Value, decimals); return true; } // Takes what is in the buffer, finds the LATEST valid reading, removes the text it used (a half-received frame stays for next time). public bool Extract(StringBuilder buf, out string weight, out bool stable) { weight = null; stable = false; string text = buf.ToString(); bool found = false; int consumed = 0; if (frame_mode == "auto" && method == "regex") { foreach (Match m in _re.Matches(text)) { if (m.Length == 0) continue; if (TryParseFrame(m.Value, out string w, out bool st)) { weight = w; stable = st; found = true; } consumed = m.Index + m.Length; } } else if (frame_mode == "length") { int L = Math.Max(1, frame_length); while (text.Length - consumed >= L) { if (TryParseFrame(text.Substring(consumed, L), out string w, out bool st)) { weight = w; stable = st; found = true; } consumed += L; } } else { string t = TerminatorText(); if (t.Length == 0) t = "\n"; int idx; while ((idx = text.IndexOf(t, consumed, StringComparison.Ordinal)) >= 0) { if (TryParseFrame(text.Substring(consumed, idx - consumed), out string w, out bool st)) { weight = w; stable = st; found = true; } consumed = idx + t.Length; } } if (consumed > 0) buf.Remove(0, Math.Min(consumed, buf.Length)); return found; } } class SyncReply { public int version { get; set; } public IndicatorProtocol config { get; set; } public List live_cameras { get; set; } } // ===================================================================== // Camera health. A rejected login must NOT be retried every few seconds: cameras (Hikvision and others) lock the account after a few // wrong attempts, and then even the right password is refused for about 30 minutes. So the agent finds out WHY a camera failed, // waits accordingly, tries again at once if the cloud sends a different login, and tells the cloud page what is wrong. // ===================================================================== class CamHealth { public string Status = ""; public string Message = ""; public int Failures; public DateTime RetryAfter = DateTime.MinValue; public string Sig = ""; } static readonly Dictionary _health = new Dictionary(); // the login exactly as the cloud sent it (compared, never printed) static string CamSig(CameraInfo c) { return string.Join("|", c.ip_address, c.rtsp_port, c.rtsp_path, c.username, c.password, c.problem); } internal static string ClassifyCameraError(string text) { string t = (text ?? "").ToLowerInvariant(); if (t.Contains("401") || t.Contains("unauthorized") || t.Contains("authorization failed") || t.Contains("authentication failed")) return "auth_failed"; if (t.Contains("404") || t.Contains("451") || t.Contains("not found")) return "not_found"; // 451 = "parameter not understood": the camera does not know this address if (t.Contains("timed out") || t.Contains("timeout") || t.Contains("refused") || t.Contains("no route") || t.Contains("unreachable") || t.Contains("could not resolve") || t.Contains("name or service not known") || t.Contains("host is down") || t.Contains("connection to tcp")) return "unreachable"; return "error"; } // how long to leave a camera alone after a failure internal static TimeSpan RetryDelay(string status, int failures) { switch (status) { case "auth_failed": return TimeSpan.FromMinutes(10); // never hammer a rejected login case "not_found": return TimeSpan.FromSeconds(60); case "no_password": case "password_unreadable": return TimeSpan.FromSeconds(30); default: return TimeSpan.FromSeconds(Math.Min(60, 4 * Math.Pow(2, Math.Max(0, Math.Min(failures, 6) - 1)))); // 4, 8, 16, 32, 60 s } } static string CameraMessage(string status, CameraInfo cam, string detail) { switch (status) { case "auth_failed": return "The camera rejected the login (401). Check the user name and password in Camera Settings. To avoid locking the camera the weighbridge PC waits 10 minutes before it tries again - unless the password is changed."; case "not_found": return "The camera does not understand this picture address (404 or 451). Check the RTSP path in Camera Settings. XMEye / Xiongmai DVRs need the XMEye path option, with the channel number of the camera on the DVR."; case "unreachable": return $"Cannot reach the camera at {cam.ip_address}:{cam.rtsp_port}. Check that it is switched on, the IP address is right and it is on the same network as the weighbridge PC."; case "no_password": return "No password is saved for this camera. In Camera Settings click Edit on this camera, type the password and Save."; case "password_unreadable": return "The saved password cannot be read (the server's encryption key changed since it was typed). In Camera Settings click Edit on this camera, type the password again and Save."; default: return "The camera could not be read" + (string.IsNullOrEmpty(detail) ? "." : ": " + detail); } } static void SetHealth(CameraInfo cam, string status, string detail = "") { string log = null; lock (_health) { CamHealth h; if (!_health.TryGetValue(cam.camera_id, out h)) { h = new CamHealth(); _health[cam.camera_id] = h; } bool changed = h.Status != status; h.Status = status; h.Sig = CamSig(cam); h.Message = status == "ok" ? "" : CameraMessage(status, cam, detail); if (status == "ok") { h.Failures = 0; h.RetryAfter = DateTime.MinValue; } else { h.Failures++; h.RetryAfter = DateTime.UtcNow + RetryDelay(status, h.Failures); } if (changed) log = status == "ok" ? $"[camera] {cam.camera_name}: working" : $"[camera] {cam.camera_name}: {status} - {h.Message}"; } if (log != null) Console.WriteLine(log); } // false = leave this camera alone for now (known-bad login, or it failed a moment ago and the waiting time is not over) static bool CanTry(CameraInfo cam, out string why) { why = ""; if (!string.IsNullOrEmpty(cam.problem)) { SetHealth(cam, cam.problem); why = cam.problem; return false; } lock (_health) { CamHealth h; if (_health.TryGetValue(cam.camera_id, out h) && h.Status != "" && h.Status != "ok" && h.Sig == CamSig(cam) && DateTime.UtcNow < h.RetryAfter) { why = $"{h.Status}, next try in {(int)(h.RetryAfter - DateTime.UtcNow).TotalSeconds} s"; return false; } } return true; } // one short phrase per camera for the failed-photo note the operator sees next to the weighing internal static string HealthShort(string status) { switch (status) { case "auth_failed": return "login rejected (wrong user name or password)"; case "not_found": return "wrong picture address (RTSP path)"; case "unreachable": return "cannot be reached"; case "no_password": return "no password saved"; case "password_unreadable": return "saved password cannot be read - type it again"; default: return "could not be read"; } } static string HealthStatus(int cameraId) { lock (_health) { CamHealth h; return _health.TryGetValue(cameraId, out h) ? h.Status : ""; } } static string HealthMessage(int cameraId) { lock (_health) { CamHealth h; return _health.TryGetValue(cameraId, out h) && h.Status != "ok" ? h.Message : ""; } } // what the cloud page shows next to each camera static Dictionary CameraStatusReport() { var d = new Dictionary(); lock (_health) foreach (var kv in _health) if (kv.Value.Status != "") d[kv.Key.ToString()] = new Dictionary { ["status"] = kv.Value.Status, ["message"] = kv.Value.Message }; return d; } // ===================================================================== // CCTV capture - polls the cloud for pending capture requests (same // reliable polling philosophy as the weight loop), fetches this site's // camera credentials, grabs one frame per active camera via ffmpeg, and // uploads each snapshot. // // IMPORTANT: the RTSP path/port/credentials come from Camera Settings in // the admin panel. The ffmpeg command below is a standard, well-documented // way to pull a single frame from an RTSP stream - but whether THIS exact // command succeeds against your specific camera model/firmware has not // been verified against real hardware. If it fails, check the console // output here first (ffmpeg prints a clear error), then adjust the RTSP // path in Camera Settings and try again - the same calibration step the // weight indicator needed before TryParseWeight() was correct. // ===================================================================== class CameraInfo { public int camera_id { get; set; } public string camera_name { get; set; } public string ip_address { get; set; } public string username { get; set; } public string password { get; set; } public string rtsp_path { get; set; } public int rtsp_port { get; set; } public string position { get; set; } public string device_id { get; set; } // the weighbridge this camera belongs to (empty = shared by every weighbridge of the site) public string problem { get; set; } // set by the cloud when the saved password cannot be used (then the agent does not knock on the camera) } class CaptureRequest { public int request_id { get; set; } public int job_id { get; set; } public string source { get; set; } public string device_id { get; set; } } static void StartCameraCaptureLoop() { Task.Run(async () => { while (true) { try { var req = await PollForCaptureRequest(); if (req != null) { Console.WriteLine($"[camera] capture request for job {req.job_id}{(req.source == "manual" ? " (manual)" : "")}"); bool anySuccess = false; string why = ""; try { _captureNote = ""; anySuccess = await CaptureAndUploadAll(req.job_id, req.request_id, req.device_id); if (!anySuccess) why = !string.IsNullOrEmpty(_captureNote) ? _captureNote : "No camera produced a usable snapshot - check ffmpeg output above and the RTSP path in Camera Settings."; } catch (Exception ex) { why = "Agent error: " + ex.Message; Console.WriteLine($"[camera] capture failed: {ex.Message}"); } await CompleteCaptureRequest(req.request_id, anySuccess ? "Done" : "Failed", why); // never left hanging: the operator sees the reason } } catch (Exception ex) { Console.WriteLine($"[camera] loop error: {ex.Message}"); } await Task.Delay(CameraPollIntervalMs); } }); } static async Task PollForCaptureRequest() { var payload = JsonSerializer.Serialize(new { device_id = DeviceId }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/camera_capture_poll.php") { Content = content }; httpReq.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(httpReq); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode || body == "null" || string.IsNullOrWhiteSpace(body)) return null; return JsonSerializer.Deserialize(body); } static async Task> FetchCameraList() { var payload = JsonSerializer.Serialize(new { device_id = DeviceId }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/camera_list.php") { Content = content }; httpReq.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(httpReq); var body = await resp.Content.ReadAsStringAsync(); return ParseCameraList((int)resp.StatusCode, resp.IsSuccessStatusCode, body); } // Never throws, and always says WHY the list is empty. static List ParseCameraList(int status, bool ok, string body) { if (!ok) { Console.WriteLine($"[camera] camera list refused by the cloud: HTTP {status} {Brief(body)}"); return new List(); } if (string.IsNullOrWhiteSpace(body)) { Console.WriteLine($"[camera] camera list: the cloud answered HTTP {status} with an EMPTY reply (an error inside camera_list.php - see the cPanel error log)"); return new List(); } try { var list = JsonSerializer.Deserialize>(body); if (list == null || list.Count == 0) Console.WriteLine("[camera] camera list is empty: no ACTIVE camera for this site in Camera Settings"); return list ?? new List(); } catch (Exception ex) { Console.WriteLine($"[camera] camera list could not be read ({ex.Message}): {Brief(body)}"); return new List(); } } static string Brief(string s) { s = (s ?? "").Replace("\r", " ").Replace("\n", " ").Trim(); return s.Length > 200 ? s.Substring(0, 200) + "..." : s; } // Every active camera of the site is photographed AT THE SAME TIME (4 cameras take about as long as 1) and each picture is uploaded. static string _captureNote = ""; // The cameras that take part in a weighing done on one weighbridge: its own cameras plus the shared ones (no weighbridge tag). // No weighbridge named (an older request) = every camera of the site, as before. static List CamerasForWeighbridge(List all, string deviceId) { if (string.IsNullOrEmpty(deviceId)) return all; return all.Where(c => string.IsNullOrEmpty(c.device_id) || c.device_id == deviceId).ToList(); } static async Task CaptureAndUploadAll(int jobId, int requestId, string deviceId = null) { var cameras = CamerasForWeighbridge(await FetchCameraList(), deviceId); if (cameras.Count == 0) { _captureNote = "No active camera of this weighbridge (" + (deviceId ?? "site") + ") was returned by the cloud (see the agent log)."; return false; } var tasks = cameras.Select(cam => Task.Run(async () => { string tempFile = Path.Combine(Path.GetTempPath(), $"cam_{cam.camera_id}_{jobId}_{requestId}.jpg"); try { if (!CaptureSnapshot(cam, tempFile)) return false; return await UploadPhoto(jobId, cam.camera_id, tempFile, requestId); } finally { try { File.Delete(tempFile); } catch { /* best effort cleanup */ } } })).ToList(); var results = await Task.WhenAll(tasks); Console.WriteLine($"[camera] job {jobId}{(string.IsNullOrEmpty(deviceId) ? "" : " (weighbridge " + deviceId + ")")}: {results.Count(ok => ok)} of {results.Length} cameras uploaded"); if (!results.Any(ok => ok)) { string note = string.Join("; ", cameras.Select(c => { string st = HealthStatus(c.camera_id); return st == "" || st == "ok" ? "" : $"{c.camera_name}: {HealthShort(st)}"; }).Where(x => x != "")); if (note.Length > 0) { note += ". See the Live Cameras page."; _captureNote = note.Length > 240 ? note.Substring(0, 240) : note; } } return results.Any(ok => ok); } static string RtspUrl(CameraInfo cam) { // user name / password may contain @ : / # ? % - inside a URL they must be percent-encoded, or the camera receives a different login (401) string user = Uri.EscapeDataString(cam.username ?? ""), pass = Uri.EscapeDataString(cam.password ?? ""); string path = cam.rtsp_path ?? ""; // XMEye / Xiongmai DVRs and cameras take the login INSIDE the path, e.g. /user=admin&password=&channel=1&stream=0.sdp?real_stream // Write it as /user={user}&password={password}&channel=1&... and the saved (encrypted) login is filled in here - it never shows in Camera Settings. bool loginInPath = path.Contains("{user}") || path.Contains("{password}"); if (loginInPath) path = path.Replace("{user}", user).Replace("{password}", pass); string auth = (!loginInPath && !string.IsNullOrEmpty(cam.username)) ? $"{user}:{pass}@" : ""; return $"rtsp://{auth}{cam.ip_address}:{cam.rtsp_port}{path}"; } // ffmpeg repeats the whole address - password included - in its messages. That must never reach the screen or the log file. internal static string Mask(string text) { string t = Regex.Replace(text ?? "", @"(rtsp://[^:/@\s]*:)[^@\s]*@", "$1***@"); // rtsp://user:PASSWORD@host return Regex.Replace(t, @"(password=)[^&\s]*?(?=_channel=|&|\s|\?|$)", "$1***", RegexOptions.IgnoreCase); // .../user=admin&password=PASSWORD&channel=1 (XMEye style) } static bool CaptureSnapshot(CameraInfo cam, string outputPath) { string why; if (!CanTry(cam, out why)) { Console.WriteLine($"[camera] {cam.camera_name}: skipped ({why})"); return false; } string rtspUrl = RtspUrl(cam); // -y overwrite, -rtsp_transport tcp is more firewall-friendly than the UDP default, // -frames:v 1 grabs exactly one frame; the 8 s limit below means one unreachable camera never blocks the others. string args = $"-hide_banner -rtsp_transport tcp -i \"{rtspUrl}\" -frames:v 1 -update 1 -y \"{outputPath}\""; try { var psi = new ProcessStartInfo { FileName = FfmpegPath, Arguments = args, RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using (var proc = Process.Start(psi)) { var err = new StringBuilder(); proc.ErrorDataReceived += (s, e) => { if (e.Data != null) lock (err) err.AppendLine(e.Data); }; // read as it comes: the timeout below is then real proc.OutputDataReceived += (s, e) => { }; proc.BeginErrorReadLine(); proc.BeginOutputReadLine(); bool exited = proc.WaitForExit(8000); // 8s timeout per camera if (!exited) { try { proc.Kill(); } catch { } Console.WriteLine($"[camera] {cam.camera_name}: timed out"); SetHealth(cam, "unreachable"); return false; } proc.WaitForExit(); // let the last error lines arrive string stderr; lock (err) { stderr = err.ToString(); } if (proc.ExitCode != 0 || !File.Exists(outputPath)) { Console.WriteLine($"[camera] {cam.camera_name}: ffmpeg failed (exit {proc.ExitCode}). stderr: {Mask(stderr)}"); SetHealth(cam, ClassifyCameraError(stderr), Brief(Mask(stderr))); return false; } Console.WriteLine($"[camera] {cam.camera_name}: captured OK"); SetHealth(cam, "ok"); return true; } } catch (Exception ex) { Console.WriteLine($"[camera] {cam.camera_name}: could not run ffmpeg ({ex.Message}). Is FfmpegPath correct in App.config and ffmpeg installed?"); return false; } } static async Task UploadPhoto(int jobId, int cameraId, string filePath, int requestId = 0) { try { using (var form = new MultipartFormDataContent()) { form.Add(new StringContent(DeviceId), "device_id"); form.Add(new StringContent(jobId.ToString()), "job_id"); form.Add(new StringContent(cameraId.ToString()), "camera_id"); form.Add(new StringContent(requestId.ToString()), "request_id"); // the cloud reads which weighing / manual-or-auto from the request var fileBytes = File.ReadAllBytes(filePath); // synchronous - .NET Framework doesn't have ReadAllBytesAsync var fileContent = new ByteArrayContent(fileBytes); fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); form.Add(fileContent, "photo", Path.GetFileName(filePath)); var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/transaction_photo_upload.php") { Content = form }; httpReq.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(httpReq); if (!resp.IsSuccessStatusCode) Console.WriteLine($"[camera] upload rejected: {(int)resp.StatusCode}"); return resp.IsSuccessStatusCode; } } catch (Exception ex) { Console.WriteLine($"[camera] upload failed: {ex.Message}"); return false; } } // ===================================================================== // Live view. While someone watches a camera on the cloud panel, the cloud lists that camera in every sync reply. For each one // listed we keep ONE ffmpeg running that writes a small picture about once a second; a loop uploads every new picture. When nobody // watches any more (or the panel says so) the ffmpeg is stopped - an unwatched camera costs nothing. // ===================================================================== class LiveStream { public CameraInfo Cam; public Process Proc; public string File; public DateTime LastWanted, StartedAt, LastUploaded = DateTime.MinValue, RetryAfter = DateTime.MinValue; public readonly StringBuilder Err = new StringBuilder(); // what ffmpeg said (password hidden), to find out why it stopped } static readonly Dictionary _live = new Dictionary(); static List _camCache = new List(); static DateTime _camCacheAt = DateTime.MinValue; static bool _liveExitHooked = false; static async Task UpdateLiveFeeds(List ids) { ids = ids ?? new List(); _liveWanted = CameraCaptureEnabled && ids.Count > 0; if (!CameraCaptureEnabled) return; if (ids.Count > 0 && (DateTime.UtcNow - _camCacheAt > TimeSpan.FromSeconds(20) || ids.Any(i => !_camCache.Any(c => c.camera_id == i)))) { try { _camCache = await FetchCameraList(); _camCacheAt = DateTime.UtcNow; } catch (Exception ex) { Console.WriteLine($"[live] camera list: {ex.Message}"); } } lock (_live) { if (!_liveExitHooked) { _liveExitHooked = true; AppDomain.CurrentDomain.ProcessExit += (s, e) => StopAllLive(); } foreach (int id in ids) { var cam = _camCache.FirstOrDefault(c => c.camera_id == id); if (cam == null) continue; if (!_live.TryGetValue(id, out var ls)) { ls = new LiveStream { Cam = cam, File = Path.Combine(Path.GetTempPath(), $"wms_live_{id}.jpg") }; _live[id] = ls; } if (CamSig(ls.Cam) != CamSig(cam)) ls.RetryAfter = DateTime.MinValue; // the cloud sent a different login (password changed): try again at once ls.Cam = cam; ls.LastWanted = DateTime.UtcNow; } } } static void StopLive(LiveStream ls) { try { if (ls.Proc != null && !ls.Proc.HasExited) ls.Proc.Kill(); } catch { } try { ls.Proc?.Dispose(); } catch { } ls.Proc = null; try { File.Delete(ls.File); } catch { } } static void StopAllLive() { lock (_live) { foreach (var ls in _live.Values) StopLive(ls); _live.Clear(); } } static void StartLiveProcess(LiveStream ls) { try { File.Delete(ls.File); } catch { } // 2 pictures per second, 640 px wide, written to a temp name and renamed (-atomic_writing) so a half-written picture is never uploaded string args = $"-hide_banner -loglevel error -fflags nobuffer -flags low_delay -probesize 100000 -analyzeduration 500000 -rtsp_transport tcp -i \"{RtspUrl(ls.Cam)}\" -an -vf \"fps=2,scale=640:-2\" -q:v 6 -f image2 -update 1 -atomic_writing 1 -y \"{ls.File}\""; var psi = new ProcessStartInfo { FileName = FfmpegPath, Arguments = args, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true }; ls.Proc = Process.Start(psi); ls.Err.Clear(); ls.Proc.ErrorDataReceived += (s, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) { string line = Mask(e.Data); lock (ls.Err) { if (ls.Err.Length < 800) ls.Err.AppendLine(line); } Console.WriteLine($"[live] {ls.Cam.camera_name}: {line}"); } }; ls.Proc.BeginErrorReadLine(); ls.Proc.BeginOutputReadLine(); ls.StartedAt = DateTime.UtcNow; Console.WriteLine($"[live] streaming {ls.Cam.camera_name}"); } static void StartLiveFeedLoop() { Task.Run(async () => { while (true) { try { List snapshot; lock (_live) { snapshot = _live.Values.ToList(); } foreach (var ls in snapshot) { if (DateTime.UtcNow - ls.LastWanted > TimeSpan.FromSeconds(20)) // nobody is watching any more { Console.WriteLine($"[live] stopped {ls.Cam.camera_name} (nobody watching)"); lock (_live) { StopLive(ls); _live.Remove(ls.Cam.camera_id); } continue; } if (ls.Proc == null || ls.Proc.HasExited) { if (ls.Proc != null) // it ended by itself: find out why, and how long to leave the camera alone { string err; lock (ls.Err) { err = ls.Err.ToString(); } try { ls.Proc.Dispose(); } catch { } ls.Proc = null; SetHealth(ls.Cam, ClassifyCameraError(err), Brief(err)); lock (_health) { CamHealth h; if (_health.TryGetValue(ls.Cam.camera_id, out h)) ls.RetryAfter = h.RetryAfter; } } string why; if (!CanTry(ls.Cam, out why)) continue; // known-bad login / address: do not knock again yet if (DateTime.UtcNow < ls.RetryAfter) continue; ls.RetryAfter = DateTime.UtcNow.AddSeconds(4); try { StartLiveProcess(ls); } catch (Exception ex) { Console.WriteLine($"[live] {ls.Cam.camera_name}: could not run ffmpeg ({ex.Message})"); SetHealth(ls.Cam, "error", "ffmpeg could not be started - check FfmpegPath in App.config"); } continue; } var written = File.Exists(ls.File) ? File.GetLastWriteTimeUtc(ls.File) : DateTime.MinValue; if (written > ls.LastUploaded) { ls.LastUploaded = written; byte[] bytes = null; try { bytes = File.ReadAllBytes(ls.File); } catch { } if (bytes != null && bytes.Length > 500) { SetHealth(ls.Cam, "ok"); // a picture arrived: the camera works bool keep = await UploadLiveFrame(ls.Cam.camera_id, bytes); if (!keep) ls.LastWanted = DateTime.UtcNow - TimeSpan.FromSeconds(30); // the cloud says nobody watches: stop on the next pass } } } } catch (Exception ex) { Console.WriteLine($"[live] loop error: {ex.Message}"); } await Task.Delay(150); } }); } static async Task UploadLiveFrame(int cameraId, byte[] jpeg) { try { var content = new ByteArrayContent(jpeg); content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); var req = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/camera_live_post.php?camera_id={cameraId}") { Content = content }; req.Headers.Add("X-Device-Key", DeviceKey); req.Headers.Add("X-Device-Id", DeviceId); var resp = await _http.SendAsync(req); if (!resp.IsSuccessStatusCode) { Console.WriteLine($"[live] upload rejected: {(int)resp.StatusCode}"); return true; } string body = await resp.Content.ReadAsStringAsync(); return !body.Contains("\"keep\":false"); } catch (Exception ex) { Console.WriteLine($"[live] upload failed: {ex.Message}"); return true; } } static async Task CompleteCaptureRequest(int requestId, string status, string failReason) { var payload = JsonSerializer.Serialize(new { device_id = DeviceId, request_id = requestId, status, fail_reason = failReason }); for (int attempt = 1; attempt <= 3; attempt++) { try { var content = new StringContent(payload, Encoding.UTF8, "application/json"); var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{CloudBaseUrl}/api/camera_capture_complete.php") { Content = content }; httpReq.Headers.Add("X-Device-Key", DeviceKey); var resp = await _http.SendAsync(httpReq); if (resp.IsSuccessStatusCode) return; } catch (Exception ex) { Console.WriteLine($"[camera] could not report the result of request {requestId} (try {attempt}/3): {ex.Message}"); } await Task.Delay(1000); } } } }