1033 lines
40 KiB
C#
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));
|
|
}
|