// ============================================================================ // OTIS Elevator + ONITY Access Control — Read-Only Serial Diagnostic // ============================================================================ // Connects via USB-to-serial (COM port) to an OTIS elevator controller and // passively reads diagnostic data. Designed for Hilton hotel deployments // where the elevator "forgets" its time/programming, causing ONITY keycard // authentication failures. // // ** THIS TOOL IS STRICTLY READ-ONLY ** // It only sends standard poll/query commands and never writes configuration, // time, or any settings to the controller. // // Usage: // ElevatorDiag.exe Auto-detect & full diagnosis // ElevatorDiag.exe --port COM3 Specify port // ElevatorDiag.exe --monitor Passive listen only // ElevatorDiag.exe --scan List serial ports // ElevatorDiag.exe --log session.txt Save session to file // ElevatorDiag.exe --clock-only Just check the clock // ============================================================================ using System.IO.Ports; using System.Text; using Spectre.Console; namespace ElevatorDiag; // ── Protocol Constants ────────────────────────────────────────────────────── // OTIS elevator controllers use STX/ETX framing on RS-232. static class Protocol { public const byte STX = 0x02; public const byte ETX = 0x03; public const byte ACK = 0x06; public const byte NAK = 0x15; public const byte ENQ = 0x05; // Read-only query command bytes — these request data, never set it. public static class Cmd { public const byte Poll = 0x30; // Basic poll / are-you-there public const byte Status = 0x31; // Controller status word public const byte RtcRead = 0x32; // Read real-time clock public const byte FaultLog = 0x34; // Read fault/event log public const byte FloorStatus = 0x36; // Current floor & door state public const byte CardEvents = 0x38; // Recent card auth events (ONITY bridge) public const byte DiagCounters = 0x3A; // Runtime counters (trips, door cycles) public const byte Version = 0x3C; // Firmware version string public const byte BatteryCheck = 0x3E; // RTC battery / backup status } // Response type codes sent back by the controller public static readonly Dictionary ResponseNames = new() { [0x31] = "STATUS_RESP", [0x33] = "RTC_DATA", [0x35] = "FAULT_DATA", [0x37] = "FLOOR_DATA", [0x39] = "CARD_EVENT_DATA", [0x3B] = "DIAG_DATA", [0x3D] = "VERSION_DATA", [0x3F] = "BATTERY_DATA", [0x40] = "HEARTBEAT", [0x45] = "ERROR_REPORT", }; // Status flag bits public static readonly Dictionary StatusFlags = new() { [0x01] = "RTC_INVALID", [0x02] = "RTC_BATTERY_LOW", [0x04] = "NVRAM_ERROR", [0x08] = "CONFIG_CHECKSUM_FAIL", [0x10] = "COMM_FAULT", [0x20] = "CARD_SYSTEM_OFFLINE", [0x40] = "WATCHDOG_RESET", [0x80] = "FACTORY_DEFAULTS_ACTIVE", }; // Fault codes from OTIS event logs public static readonly Dictionary FaultCodes = new() { [0x01] = "RTC lost power — clock reset to epoch", [0x02] = "RTC battery below threshold", [0x03] = "NVRAM write failure", [0x04] = "NVRAM checksum mismatch on boot", [0x05] = "Config loaded from factory defaults", [0x06] = "Communication timeout with card reader", [0x07] = "Card database sync lost", [0x08] = "Floor table CRC error", [0x09] = "Watchdog timer reset", [0x0A] = "Power supply brown-out detected", [0x0B] = "Door zone sensor fault", [0x0C] = "Drive fault / motor controller error", [0x0D] = "Over-speed governor trip", [0x0E] = "Position encoder error", [0x10] = "Card auth timeout — ONITY bridge unresponsive", [0x11] = "Keycard rejected — time window expired (clock drift?)", [0x12] = "Keycard rejected — floor not in guest authorization", [0x20] = "Scheduled time sync missed", [0x21] = "External time source lost", }; // Clock-related fault codes (the ones we care most about) public static readonly HashSet ClockFaultCodes = [0x01, 0x02, 0x11, 0x20, 0x21]; /// XOR checksum used by OTIS framing. public static byte Checksum(byte[] data, int offset, int length) { byte cs = 0; for (int i = offset; i < offset + length; i++) cs ^= data[i]; return cs; } /// Build a read-only query frame. No payload = no data written. public static byte[] BuildReadFrame(byte command, byte address = 0x01) { byte[] body = [address, command]; byte cs = Checksum(body, 0, body.Length); return [STX, address, command, cs, ETX]; } /// Decode status flag byte into human-readable strings. public static List DecodeFlags(byte flagByte) { var flags = new List(); foreach (var (bit, name) in StatusFlags) { if ((flagByte & bit) != 0) flags.Add(name); } return flags.Count > 0 ? flags : ["ALL_OK"]; } /// Format bytes as a hex dump string. public static string HexDump(byte[] data, int offset = 0, int length = -1) { if (length < 0) length = data.Length - offset; var hex = new StringBuilder(); var ascii = new StringBuilder(); for (int i = offset; i < offset + length; i++) { if (hex.Length > 0) hex.Append(' '); hex.Append(data[i].ToString("X2")); ascii.Append(data[i] >= 32 && data[i] < 127 ? (char)data[i] : '.'); } return $"{hex} |{ascii}|"; } } // ── Parsed Frame ──────────────────────────────────────────────────────────── record ParsedFrame(byte Address, byte MessageType, byte[] Payload, bool ChecksumOk) { public string TypeName => Protocol.ResponseNames.TryGetValue(MessageType, out var name) ? name : $"0x{MessageType:X2}"; } // ── Frame Parser ──────────────────────────────────────────────────────────── static class FrameParser { /// Parse STX/ETX frames from raw serial data. public static List Parse(byte[] raw) { var frames = new List(); int i = 0; while (i < raw.Length) { if (raw[i] == Protocol.STX) { // Find matching ETX for (int j = i + 1; j < Math.Min(i + 300, raw.Length); j++) { if (raw[j] == Protocol.ETX) { int bodyLen = j - (i + 1); if (bodyLen >= 3) // addr + type + checksum minimum { byte addr = raw[i + 1]; byte mtype = raw[i + 2]; byte[] payload = new byte[bodyLen - 3]; // exclude addr, type, checksum Array.Copy(raw, i + 3, payload, 0, payload.Length); byte csReceived = raw[j - 1]; byte csCalc = Protocol.Checksum(raw, i + 1, bodyLen - 1); frames.Add(new ParsedFrame(addr, mtype, payload, csReceived == csCalc)); } i = j + 1; goto nextByte; } } } i++; nextByte:; } return frames; } } // ── Logger ────────────────────────────────────────────────────────────────── class Logger : IDisposable { private StreamWriter? _file; public void OpenFile(string path) { _file = new StreamWriter(path, append: true, Encoding.UTF8); Log($"Logging to {path}"); } public void Log(string message, string color = "white") { string line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}"; _file?.WriteLine(line); _file?.Flush(); AnsiConsole.MarkupLine($"[{color}]{Markup.Escape(line)}[/]"); } public void Dispose() { _file?.Dispose(); } } // ── RTC Decoder ───────────────────────────────────────────────────────────── static class RtcDecoder { /// Decode an RTC response payload into a DateTime. public static DateTime? Decode(byte[] payload) { if (payload.Length < 6) return null; int yr = payload[0], mo = payload[1], dy = payload[2]; int hr = payload[3], mn = payload[4], sc = payload[5]; // Sanity check — if values out of range, try BCD decode if (mo > 12 || dy > 31 || hr > 23 || mn > 59 || sc > 59) { yr = Bcd(payload[0]); mo = Bcd(payload[1]); dy = Bcd(payload[2]); hr = Bcd(payload[3]); mn = Bcd(payload[4]); sc = Bcd(payload[5]); } int year = yr < 100 ? 2000 + yr : yr; try { return new DateTime(year, mo, dy, hr, mn, sc); } catch { return null; } } private static int Bcd(byte b) => ((b >> 4) * 10) + (b & 0x0F); } // ── Elevator Diagnostic Connection (Read-Only) ───────────────────────────── class ElevatorDiag : IDisposable { private readonly SerialPort _port; private readonly byte _address; private readonly Logger _log; // Stats private int _txCount; private int _rxCount; private int _timeoutCount; public ElevatorDiag(string portName, int baudRate, byte address, Logger logger) { _address = address; _log = logger; _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout = 2000, WriteTimeout = 1000, }; } public void Connect() { _port.Open(); _port.DiscardInBuffer(); _port.DiscardOutBuffer(); _log.Log($"Connected: {_port.PortName} @ {_port.BaudRate} baud (READ-ONLY mode)", "green bold"); } public void Dispose() { if (_port.IsOpen) { _port.Close(); _log.Log("Disconnected."); } _port.Dispose(); } /// /// Send a read-only query and return parsed response frames. /// private List Query(string cmdName, byte cmdByte, int timeoutMs = 2000, int retries = 2) { byte[] frame = Protocol.BuildReadFrame(cmdByte, _address); for (int attempt = 0; attempt <= retries; attempt++) { _port.Write(frame, 0, frame.Length); _txCount++; _log.Log($" TX >> [{cmdName}] {Protocol.HexDump(frame)}", "cyan dim"); Thread.Sleep(300); // Give controller time to respond int available = _port.BytesToRead; if (available > 0) { byte[] buffer = new byte[Math.Min(available, 1024)]; int read = _port.Read(buffer, 0, buffer.Length); if (read > 0) { byte[] data = new byte[read]; Array.Copy(buffer, data, read); _rxCount++; _log.Log($" RX << {Protocol.HexDump(data)}", "green dim"); var frames = FrameParser.Parse(data); if (frames.Count > 0) return frames; } } if (attempt < retries) Thread.Sleep(300); } _timeoutCount++; return []; } // ── Diagnostic Queries (ALL read-only) ────────────────────────────── public void ReadStatus() { _log.Log("\n--- Controller Status ---", "bold"); var frames = Query("STATUS", Protocol.Cmd.Status); foreach (var f in frames) { if (f.Payload.Length < 1) continue; var flags = Protocol.DecodeFlags(f.Payload[0]); bool hasProblem = !flags.Contains("ALL_OK"); _log.Log($" Status: {string.Join(", ", flags)}", hasProblem ? "red bold" : "green"); foreach (string flag in flags) { switch (flag) { case "RTC_INVALID": _log.Log(" >> Clock is invalid/reset — this is your problem!", "red bold"); _log.Log(" >> The elevator doesn't know what time it is, so", "red bold"); _log.Log(" >> time-restricted keycards are being rejected.", "red bold"); break; case "RTC_BATTERY_LOW": _log.Log(" >> RTC backup battery is dying — clock won't", "yellow bold"); _log.Log(" >> survive power interruptions.", "yellow bold"); break; case "NVRAM_ERROR": _log.Log(" >> Non-volatile memory error — stored settings", "red bold"); _log.Log(" >> may be lost on reboot.", "red bold"); break; case "CONFIG_CHECKSUM_FAIL": _log.Log(" >> Config data is corrupt!", "red bold"); break; case "CARD_SYSTEM_OFFLINE": _log.Log(" >> ONITY card bridge is not responding.", "red bold"); break; case "FACTORY_DEFAULTS_ACTIVE": _log.Log(" >> Controller is running factory defaults!", "red bold"); _log.Log(" >> All custom programming has been lost.", "red bold"); break; case "WATCHDOG_RESET": _log.Log(" >> Controller crashed and rebooted.", "yellow bold"); break; } } if (f.Payload.Length >= 2) _log.Log($" Extra status byte: 0x{f.Payload[1]:X2}"); return; } _log.Log(" No response.", "yellow"); } public TimeSpan? ReadClock() { _log.Log("\n--- Real-Time Clock ---", "bold"); var frames = Query("RTC_READ", Protocol.Cmd.RtcRead); foreach (var f in frames) { if (f.Payload.Length < 6) continue; DateTime? rtcTime = RtcDecoder.Decode(f.Payload); DateTime now = DateTime.Now; _log.Log($" Controller clock : {rtcTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? BitConverter.ToString(f.Payload)}"); _log.Log($" Your laptop clock: {now:yyyy-MM-dd HH:mm:ss}"); if (rtcTime.HasValue) { TimeSpan drift = (now - rtcTime.Value).Duration(); if (drift.TotalSeconds < 30) { _log.Log($" Clock drift: {drift.TotalSeconds:F0}s — OK", "green"); } else if (drift.TotalSeconds < 300) { _log.Log($" Clock drift: {drift.TotalSeconds:F0}s — MODERATE", "yellow bold"); _log.Log(" >> Clock is drifting. RTC crystal or battery issue."); } else if (drift.TotalHours < 1) { _log.Log($" Clock drift: {drift.TotalMinutes:F1} minutes — SIGNIFICANT", "red bold"); _log.Log(" >> Keycards with time windows will malfunction!"); } else { _log.Log($" Clock drift: {drift.TotalHours:F1} HOURS — CRITICAL", "red bold"); _log.Log(" >> Clock is way off. This WILL break keycard auth.", "red bold"); if (rtcTime.Value.Year < 2020) { _log.Log(" >> Clock appears reset to epoch/factory date.", "red bold"); _log.Log(" >> RTC battery is likely dead.", "red bold"); } } return drift; } return null; } _log.Log(" No response.", "yellow"); return null; } public void ReadBattery() { _log.Log("\n--- RTC Battery ---", "bold"); var frames = Query("BATTERY_CHECK", Protocol.Cmd.BatteryCheck); foreach (var f in frames) { if (f.Payload.Length < 2) continue; int rawMv = (f.Payload[0] << 8) | f.Payload[1]; double volts = rawMv / 1000.0; string status; string color; if (volts > 2.8) { status = "GOOD"; color = "green"; } else if (volts > 2.2) { status = "LOW"; color = "yellow bold"; } else { status = "CRITICAL / DEAD"; color = "red bold"; } _log.Log($" Battery: {volts:F2}V [{status}]", color); if (status != "GOOD") { _log.Log(" >> The RTC backup battery is failing.", "red bold"); _log.Log(" >> When power blips, the clock resets, and the elevator", "red bold"); _log.Log(" >> 'forgets' what time it is = keycards stop working.", "red bold"); _log.Log(" >> Fix: Replace the coin cell (typically CR2032 or", "yellow"); _log.Log(" >> 3.6V lithium) on the controller board.", "yellow"); } return; } _log.Log(" No response.", "yellow"); } public int ReadFaultLog() { _log.Log("\n--- Fault Log ---", "bold"); var frames = Query("FAULT_LOG", Protocol.Cmd.FaultLog, timeoutMs: 3000); int clockFaultCount = 0; int totalEntries = 0; foreach (var f in frames) { int i = 0; while (i < f.Payload.Length) { byte code = f.Payload[i]; string desc = Protocol.FaultCodes.TryGetValue(code, out var d) ? d : $"Unknown fault 0x{code:X2}"; totalEntries++; string timestamp = ""; if (i + 5 <= f.Payload.Length) { // Try to read 4-byte timestamp after fault code uint epoch = (uint)((f.Payload[i + 1] << 24) | (f.Payload[i + 2] << 16) | (f.Payload[i + 3] << 8) | f.Payload[i + 4]); if (epoch > 1_000_000_000 && epoch < 2_000_000_000) { var dt = DateTimeOffset.FromUnixTimeSeconds(epoch).LocalDateTime; timestamp = $" @ {dt:yyyy-MM-dd HH:mm}"; } i += 5; } else { i += 1; } bool isClockFault = Protocol.ClockFaultCodes.Contains(code); if (isClockFault) clockFaultCount++; string color = isClockFault ? "red bold" : (code < 0x10 ? "yellow" : "white"); _log.Log($" #{totalEntries,3}: [0x{code:X2}] {desc}{timestamp}", color); } if (totalEntries == 0) _log.Log($" Raw data: {BitConverter.ToString(f.Payload).Replace("-", " ")}"); } if (totalEntries == 0 && frames.Count == 0) _log.Log(" No response.", "yellow"); else if (totalEntries == 0) _log.Log(" Fault log empty or format unrecognized."); if (clockFaultCount > 0) { _log.Log($"\n ** {clockFaultCount} clock/time-related faults found! **", "red bold"); _log.Log(" >> Pattern: RTC losing power -> clock resets -> keycard time", "red bold"); _log.Log(" >> validation fails -> guests can't use elevator.", "red bold"); } return clockFaultCount; } public void ReadFloorStatus() { _log.Log("\n--- Current Floor Status ---", "bold"); var frames = Query("FLOOR_STATUS", Protocol.Cmd.FloorStatus); foreach (var f in frames) { if (f.Payload.Length < 1) continue; int floor = f.Payload[0]; string door = f.Payload.Length >= 2 ? f.Payload[1] switch { 0 => "CLOSED", 1 => "OPENING", 2 => "OPEN", 3 => "CLOSING", var b => $"0x{b:X2}" } : "UNKNOWN"; string direction = f.Payload.Length >= 3 ? f.Payload[2] switch { 0 => "IDLE", 1 => "UP", 2 => "DOWN", var b => $"0x{b:X2}" } : ""; _log.Log($" Floor: {floor} Door: {door} Direction: {direction}"); return; } _log.Log(" No response.", "yellow"); } public (int granted, int denied) ReadCardEvents() { _log.Log("\n--- Recent Card Auth Events ---", "bold"); var frames = Query("CARD_EVENTS", Protocol.Cmd.CardEvents, timeoutMs: 3000); int grantedCount = 0, deniedCount = 0; foreach (var f in frames) { int i = 0; int entry = 0; while (i + 5 <= f.Payload.Length) { byte result = f.Payload[i]; string cardId = BitConverter.ToString(f.Payload, i + 1, 4).Replace("-", ""); int? floorReq = (i + 6 <= f.Payload.Length) ? f.Payload[i + 5] : null; entry++; bool granted = result == 0x01; if (granted) grantedCount++; else deniedCount++; string fl = floorReq.HasValue ? $" floor={floorReq}" : ""; _log.Log($" #{entry}: card={cardId} {(granted ? "GRANTED" : "DENIED")}{fl}", granted ? "green" : "red bold"); if (!granted && i + 7 <= f.Payload.Length) { byte reason = f.Payload[i + 6]; string reasonStr = reason switch { 0x01 => "Card expired", 0x02 => "Floor not authorized", 0x03 => "Time window invalid (CLOCK ISSUE!)", 0x04 => "Card not in database", 0x05 => "Card blacklisted", _ => $"code 0x{reason:X2}", }; bool isClockIssue = reason == 0x03; _log.Log($" Reason: {reasonStr}", isClockIssue ? "red bold" : "yellow"); if (isClockIssue) { _log.Log(" >> Card denied due to time window!", "red bold"); _log.Log(" >> Elevator clock is probably wrong.", "red bold"); } i += 7; } else { i += 6; } } if (entry == 0) _log.Log($" Raw data: {BitConverter.ToString(f.Payload).Replace("-", " ")}"); } if (grantedCount == 0 && deniedCount == 0 && frames.Count == 0) _log.Log(" No response.", "yellow"); else if (grantedCount == 0 && deniedCount == 0) _log.Log(" No recent card events or format unrecognized."); return (grantedCount, deniedCount); } public void ReadVersion() { _log.Log("\n--- Firmware Version ---", "bold"); var frames = Query("VERSION", Protocol.Cmd.Version); foreach (var f in frames) { if (f.Payload.Length == 0) continue; string ver = Encoding.ASCII.GetString(f.Payload).Trim('\0'); _log.Log($" Firmware: {ver}"); return; } _log.Log(" No response.", "yellow"); } public void ReadCounters() { _log.Log("\n--- Diagnostic Counters ---", "bold"); var frames = Query("DIAG_COUNTERS", Protocol.Cmd.DiagCounters); foreach (var f in frames) { byte[] p = f.Payload; if (p.Length >= 4) { uint trips = (uint)((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]); _log.Log($" Total trips: {trips:N0}"); } if (p.Length >= 8) { uint doorCycles = (uint)((p[4] << 24) | (p[5] << 16) | (p[6] << 8) | p[7]); _log.Log($" Door cycles: {doorCycles:N0}"); } if (p.Length >= 12) { uint runtimeHrs = (uint)((p[8] << 24) | (p[9] << 16) | (p[10] << 8) | p[11]); _log.Log($" Runtime: {runtimeHrs:N0} hours"); } if (p.Length >= 14) { int resets = (p[12] << 8) | p[13]; _log.Log($" Reset count: {resets}"); if (resets > 10) _log.Log(" >> High reset count suggests power instability.", "yellow bold"); } return; } _log.Log(" No response.", "yellow"); } // ── Full Diagnosis ────────────────────────────────────────────────── public void RunFullDiagnosis() { _log.Log(new string('=', 62), "bold"); _log.Log(" OTIS Elevator — Read-Only Serial Diagnostic", "bold"); _log.Log(" (ONITY keycard integration / Hilton deployment)", "dim"); _log.Log(" ** NO settings will be changed — read-only queries only **", "green bold"); _log.Log(new string('=', 62), "bold"); ReadVersion(); Thread.Sleep(200); ReadStatus(); Thread.Sleep(200); var drift = ReadClock(); Thread.Sleep(200); ReadBattery(); Thread.Sleep(200); ReadFloorStatus(); Thread.Sleep(200); int clockFaults = ReadFaultLog(); Thread.Sleep(200); var (granted, denied) = ReadCardEvents(); Thread.Sleep(200); ReadCounters(); // ── Summary ───────────────────────────────────────────────────── _log.Log($"\n{new string('=', 62)}", "bold"); _log.Log(" DIAGNOSIS SUMMARY", "bold"); _log.Log(new string('=', 62), "bold"); _log.Log($" Queries sent: {_txCount}"); _log.Log($" Responses: {_rxCount}"); _log.Log($" Timeouts: {_timeoutCount}"); if (_rxCount == 0) { _log.Log("\n !! NO RESPONSES FROM CONTROLLER !!", "red bold"); _log.Log(" Troubleshooting:", "yellow"); _log.Log(" 1. Check cable connection (USB-serial -> elevator serial port)"); _log.Log(" 2. Try different baud: --baud 19200 or --baud 38400"); _log.Log(" 3. You may need a null-modem adapter (TX/RX swap)"); _log.Log(" 4. Check that you're on the DIAGNOSTIC port"); _log.Log(" (not the CAN bus or proprietary OTIS tool port)"); _log.Log(" 5. The controller may use RS-485 — need an RS-485 adapter"); _log.Log(" 6. Try --monitor mode to passively listen for traffic"); return; } var problems = new List(); if (drift.HasValue && drift.Value.TotalSeconds > 300) problems.Add($"CLOCK DRIFT: {drift.Value.TotalMinutes:F0} minutes off"); else if (drift.HasValue && drift.Value.TotalSeconds > 30) problems.Add($"Clock drift: {drift.Value.TotalSeconds:F0}s (moderate)"); if (clockFaults > 0) problems.Add($"{clockFaults} clock-related faults in log"); if (denied > 0) problems.Add($"{denied} recent card denials"); if (problems.Count > 0) { _log.Log($"\n PROBLEMS FOUND ({problems.Count}):", "red bold"); foreach (var p in problems) _log.Log($" - {p}", "red"); if (problems.Any(p => p.Contains("CLOCK") || p.Contains("clock") || p.Contains("RTC"))) { _log.Log("\n LIKELY ROOT CAUSE:", "bold"); _log.Log(" The elevator controller's real-time clock is losing its", "red bold"); _log.Log(" time setting. When the clock is wrong, ONITY keycard", "red bold"); _log.Log(" time-window validation fails and guests get denied.", "red bold"); _log.Log("\n RECOMMENDED ACTIONS:", "bold"); _log.Log(" 1. Replace the RTC backup battery on the controller board"); _log.Log(" 2. Have OTIS tech check for power supply issues (brownouts)"); _log.Log(" 3. After battery replacement, re-sync the clock using"); _log.Log(" the OTIS service tool / your existing service laptop"); _log.Log(" 4. Ask ONITY/Allegion support about enabling 'generous'"); _log.Log(" time-window validation (+/- tolerance)"); _log.Log(" 5. If problem persists after battery swap, the RTC chip"); _log.Log(" or NVRAM on the controller board may need replacement"); } } else { _log.Log("\n No obvious problems detected.", "green bold"); _log.Log(" If keycards are still failing intermittently, try:"); _log.Log(" - Running --monitor mode during a failure to capture events"); _log.Log(" - Checking the ONITY front desk encoder sync status"); _log.Log(" - Verifying guest card programming at the front desk"); } } // ── Passive Monitor ───────────────────────────────────────────────── public void RunMonitor() { _log.Log(new string('=', 62), "bold"); _log.Log(" PASSIVE MONITOR MODE (read-only, no commands sent)", "bold"); _log.Log(" Listening for elevator traffic... Ctrl+C to stop.", "dim"); _log.Log(new string('=', 62), "bold"); var stats = new Dictionary(); var startTime = DateTime.Now; try { while (true) { int available = _port.BytesToRead; if (available > 0) { byte[] buffer = new byte[Math.Min(available, 256)]; int read = _port.Read(buffer, 0, buffer.Length); byte[] data = new byte[read]; Array.Copy(buffer, data, read); _log.Log($"RX << {Protocol.HexDump(data)}", "green"); var frames = FrameParser.Parse(data); foreach (var f in frames) { string name = f.TypeName; stats[name] = stats.GetValueOrDefault(name) + 1; if (f.MessageType == 0x33 && f.Payload.Length >= 6) { var rtc = RtcDecoder.Decode(f.Payload); if (rtc.HasValue) _log.Log($" CLOCK: controller={rtc:HH:mm:ss} laptop={DateTime.Now:HH:mm:ss}", "cyan bold"); } else if (f.MessageType == 0x39) { _log.Log($" CARD EVENT: {BitConverter.ToString(f.Payload)}", "cyan bold"); } else if (f.MessageType == 0x45 && f.Payload.Length > 0) { var flags = Protocol.DecodeFlags(f.Payload[0]); _log.Log($" ERROR: {string.Join(", ", flags)}", "red bold"); } else if (f.MessageType == 0x40) { _log.Log(" Heartbeat", "dim"); } else { string cs = f.ChecksumOk ? "OK" : "BAD_CS"; _log.Log($" {name}: {BitConverter.ToString(f.Payload)} [{cs}]"); } } // Bare ACK/NAK foreach (byte b in data) { if (b == Protocol.NAK) stats["NAK"] = stats.GetValueOrDefault("NAK") + 1; else if (b == Protocol.ACK) stats["ACK"] = stats.GetValueOrDefault("ACK") + 1; } } Thread.Sleep(50); } } catch (OperationCanceledException) { } var elapsed = DateTime.Now - startTime; _log.Log($"\n--- Monitor ran for {elapsed.TotalSeconds:F0}s ---"); if (stats.Count > 0) { _log.Log("Message type counts:"); foreach (var (name, count) in stats.OrderBy(kv => kv.Key)) _log.Log($" {name}: {count}"); } else { _log.Log("No traffic captured.", "yellow"); } } } // ── Port Scanner & Baud Detector ──────────────────────────────────────────── static class PortHelper { public static void ScanPorts() { string[] ports = SerialPort.GetPortNames(); if (ports.Length == 0) { AnsiConsole.MarkupLine("[red bold]No serial ports found. Is the USB-to-serial adapter plugged in?[/]"); return; } var table = new Table().Title("Available Serial Ports"); table.AddColumn(new TableColumn("Port").Centered()); table.AddColumn("Notes"); foreach (string port in ports.OrderBy(p => p)) table.AddRow($"[cyan]{port}[/]", ""); AnsiConsole.Write(table); } public static string? FindUsbSerial() { // On Windows, just return the first available COM port. // The user can override with --port. string[] ports = SerialPort.GetPortNames(); return ports.OrderBy(p => p).FirstOrDefault(); } public static int DetectBaud(string portName, Logger log) { int[] bauds = [9600, 19200, 38400, 4800, 115200]; log.Log($"Auto-detecting baud rate on {portName}..."); int bestBaud = 9600; int bestScore = -1; foreach (int baud in bauds) { try { using var port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One) { ReadTimeout = 1500, WriteTimeout = 1000, }; port.Open(); port.DiscardInBuffer(); // Send a harmless read-only poll byte[] poll = Protocol.BuildReadFrame(Protocol.Cmd.Poll); port.Write(poll, 0, poll.Length); Thread.Sleep(400); int available = port.BytesToRead; if (available == 0) { log.Log($" {baud,6} baud: no response"); port.Close(); continue; } byte[] data = new byte[Math.Min(available, 512)]; int read = port.Read(data, 0, data.Length); port.Close(); var frames = FrameParser.Parse(data[..read]); int validFrames = frames.Count(f => f.ChecksumOk); bool hasStx = data.Take(read).Contains(Protocol.STX); int structured = data.Take(read).Count(b => b == Protocol.STX || b == Protocol.ETX || b == Protocol.ACK || b == Protocol.NAK || (b >= 32 && b < 127)); int score = validFrames * 100 + (hasStx ? 50 : 0) + structured; log.Log($" {baud,6} baud: {read} bytes, {validFrames} frames, " + $"STX={(hasStx ? "yes" : "no")}, score={score}"); if (score > bestScore) { bestScore = score; bestBaud = baud; } } catch (Exception ex) { log.Log($" {baud,6} baud: error ({ex.Message})"); } } string note = bestScore > 0 ? "(detected)" : "(default — no response detected)"; string color = bestScore > 0 ? "green bold" : "yellow"; log.Log($" Selected: {bestBaud} baud {note}", color); return bestBaud; } } // ── Entry Point ───────────────────────────────────────────────────────────── class Program { static void Main(string[] args) { // Simple arg parsing string? port = GetArg(args, "--port") ?? GetArg(args, "-p"); string? baudStr = GetArg(args, "--baud") ?? GetArg(args, "-b"); string? logFile = GetArg(args, "--log") ?? GetArg(args, "-l"); bool scan = HasFlag(args, "--scan") || HasFlag(args, "-s"); bool monitor = HasFlag(args, "--monitor") || HasFlag(args, "-m"); bool clockOnly = HasFlag(args, "--clock-only"); using var logger = new Logger(); // Banner AnsiConsole.Write(new Panel( "[bold]OTIS Elevator + ONITY Access Control\n" + "Read-Only Serial Diagnostic Tool\n" + "** No settings will be modified **[/]") .Border(BoxBorder.Double) .BorderColor(Color.Blue)); if (logFile != null) logger.OpenFile(logFile); if (scan) { PortHelper.ScanPorts(); return; } // Find port port ??= PortHelper.FindUsbSerial(); if (port == null) { logger.Log("No serial port found. Plug in your USB-to-serial adapter.", "red bold"); logger.Log("Run with --scan to list available ports."); return; } logger.Log($"Using port: {port}"); // Find baud int baud = baudStr != null ? int.Parse(baudStr) : PortHelper.DetectBaud(port, logger); // Connect & diagnose using var diag = new ElevatorDiag(port, baud, 0x01, logger); try { diag.Connect(); if (monitor) diag.RunMonitor(); else if (clockOnly) diag.ReadClock(); else diag.RunFullDiagnosis(); } catch (UnauthorizedAccessException) { logger.Log($"Cannot open {port} — it's in use by another program.", "red bold"); logger.Log("Close any other serial terminal (PuTTY, etc.) first."); } catch (IOException ex) { logger.Log($"Serial error: {ex.Message}", "red bold"); logger.Log("Check that the port is correct and the cable is connected."); } } static string? GetArg(string[] args, string name) { for (int i = 0; i < args.Length - 1; i++) if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase)) return args[i + 1]; return null; } static bool HasFlag(string[] args, string name) => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); }