ElevatorDiag/Program.cs

1033 lines
40 KiB
C#

// ============================================================================
// 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<byte, string> 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<byte, string> 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<byte, string> 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<byte> ClockFaultCodes = [0x01, 0x02, 0x11, 0x20, 0x21];
/// <summary>XOR checksum used by OTIS framing.</summary>
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;
}
/// <summary>Build a read-only query frame. No payload = no data written.</summary>
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];
}
/// <summary>Decode status flag byte into human-readable strings.</summary>
public static List<string> DecodeFlags(byte flagByte)
{
var flags = new List<string>();
foreach (var (bit, name) in StatusFlags)
{
if ((flagByte & bit) != 0)
flags.Add(name);
}
return flags.Count > 0 ? flags : ["ALL_OK"];
}
/// <summary>Format bytes as a hex dump string.</summary>
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
{
/// <summary>Parse STX/ETX frames from raw serial data.</summary>
public static List<ParsedFrame> Parse(byte[] raw)
{
var frames = new List<ParsedFrame>();
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
{
/// <summary>Decode an RTC response payload into a DateTime.</summary>
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();
}
/// <summary>
/// Send a read-only query and return parsed response frames.
/// </summary>
private List<ParsedFrame> 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<string>();
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<string, int>();
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));
}