feat(scripting): add sprintf2 command and fix prompt-wait race (v0.1.2)

- Add TeraTerm-compatible sprintf2 (C printf formatting into a string var)

- Fix SVOS power-cycle script racing ahead by gating prompt waits behind output-completion markers

- Bump version to 0.1.2; docs and README updates
This commit is contained in:
2026-06-04 14:13:43 +08:00
parent a8dc3d4a7a
commit 7b8f04e1dd
14 changed files with 1454 additions and 37 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.1.0</Version>
<Version>0.1.2</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
</PropertyGroup>
+8 -5
View File
@@ -1,4 +1,5 @@
using System.Net;
using ETTerms.Infrastructure;
using SnmpSharpNet;
namespace ETTerms.Scripting.Pdu;
@@ -19,6 +20,7 @@ public sealed class PduController : IDisposable
public bool CheckConnection()
{
var name = SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
AppLogger.Info($"[PDU] CheckConnection {_ip}: name='{name ?? "<null>"}'");
return !string.IsNullOrEmpty(name) && name.Contains("PDU");
}
@@ -44,7 +46,7 @@ public sealed class PduController : IDisposable
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Set);
pdu.VbList.Add(new Oid(oid), value);
var result = (SnmpV1Packet)target.Request(pdu, param);
@@ -58,14 +60,15 @@ public sealed class PduController : IDisposable
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Get);
pdu.VbList.Add(new Oid(oid));
var result = (SnmpV1Packet)target.Request(pdu, param);
if (result?.Pdu.ErrorStatus == 0)
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
if (result == null) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: no response (timeout)"); return null; }
if (result.Pdu.ErrorStatus != 0) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; }
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
}
catch { }
catch (Exception ex) { AppLogger.LogError($"[PDU] SNMP GET {oid} exception", ex); }
return null;
}
+307 -1
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ETTerms.Scripting.Pdu;
@@ -105,7 +106,7 @@ public sealed class TTLInterpreter : IDisposable
&& !original.StartsWith("if ") && !original.StartsWith("elseif ");
string command = GetCommand(line).ToLower();
if (!isAssignment && command != "while" && command != "if")
if (!isAssignment && command != "while" && command != "if" && command != "sprintf2")
line = ReplaceVariables(line);
if (line.StartsWith(":")) return; // label
@@ -124,6 +125,7 @@ public sealed class TTLInterpreter : IDisposable
case "logwrite": LogWrite(args); break;
case "logclose": LogClose(); break;
case "messagebox": ShowMessageBox(args); break;
case "sprintf2": ExecuteSprintf2(args); break;
case "waitall": ExecuteWaitAll(args); break;
case "sendlnall": ExecuteSendlnAll(args); break;
case "sendlngroup": ExecuteSendlnGroup(args); break;
@@ -520,6 +522,310 @@ public sealed class TTLInterpreter : IDisposable
#endregion
#region sprintf2
/// <summary>
/// <c>sprintf2 strvar FORMAT [ARG ...]</c> — C printf 風格格式化,結果存入字串變數 <c>strvar</c>。
/// 與 Tera Term sprintf2 相同:支援 c d i o u x X e E f g G a A s 與旗標 - + 0 # 空白、寬度/精度(含 <c>*</c>)。
/// 設定系統變數 result:0 成功、1 缺少格式字串、2 格式無效、3 引數無效、4 目的變數無效。
/// 格式字串保持字面值(不展開變數),僅對各引數做變數展開,故 <c>sprintf2 s '%s.' s</c> 可累加自身。
/// </summary>
private void ExecuteSprintf2(string args)
{
var tokens = TokenizeArgs(args);
if (tokens.Count < 1) { _result = 4; Output?.Invoke("[sprintf2] invalid destination variable"); return; }
string dest = tokens[0];
if (!Regex.IsMatch(dest, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{ _result = 4; Output?.Invoke("[sprintf2] invalid destination variable"); return; }
if (tokens.Count < 2) { _result = 1; Output?.Invoke("[sprintf2] no format string"); return; }
string format = StripQuotes(tokens[1]);
var fmtArgs = new List<string>();
for (int k = 2; k < tokens.Count; k++) fmtArgs.Add(ReplaceVariables(StripQuotes(tokens[k])));
try
{
_vars[dest] = CFormat(format, fmtArgs);
_result = 0;
}
catch (FormatException) { _result = 2; Output?.Invoke("[sprintf2] invalid format"); }
catch (ArgumentException) { _result = 3; Output?.Invoke("[sprintf2] invalid argument"); }
}
private static string CFormat(string format, IReadOnlyList<string> args)
{
var sb = new StringBuilder();
int ai = 0, i = 0;
while (i < format.Length)
{
char ch = format[i++];
if (ch != '%') { sb.Append(ch); continue; }
if (i < format.Length && format[i] == '%') { sb.Append('%'); i++; continue; }
bool left = false, plus = false, space = false, zero = false, alt = false;
for (; i < format.Length; i++)
{
char f = format[i];
if (f == '-') left = true;
else if (f == '+') plus = true;
else if (f == ' ') space = true;
else if (f == '0') zero = true;
else if (f == '#') alt = true;
else break;
}
int width = 0;
if (i < format.Length && format[i] == '*')
{
i++;
width = (int)ParseLong(NextArg(args, ref ai));
if (width < 0) { left = true; width = -width; }
}
else while (i < format.Length && char.IsDigit(format[i])) width = width * 10 + (format[i++] - '0');
int prec = -1;
if (i < format.Length && format[i] == '.')
{
i++;
if (i < format.Length && format[i] == '*')
{ i++; prec = (int)ParseLong(NextArg(args, ref ai)); if (prec < 0) prec = -1; }
else { prec = 0; while (i < format.Length && char.IsDigit(format[i])) prec = prec * 10 + (format[i++] - '0'); }
}
if (i >= format.Length) throw new FormatException();
char type = format[i++];
if (type == '%') { sb.Append('%'); continue; }
string body = ConvertSpec(type, plus, space, alt, prec,
NextArg(args, ref ai), out string prefix, out bool allowZero);
int pad = width - prefix.Length - body.Length;
if (pad < 0) pad = 0;
if (left) { sb.Append(prefix).Append(body).Append(' ', pad); }
else if (zero && allowZero) { sb.Append(prefix).Append('0', pad).Append(body); }
else { sb.Append(' ', pad).Append(prefix).Append(body); }
}
return sb.ToString();
}
private static string ConvertSpec(char type, bool plus, bool space, bool alt, int prec,
string arg, out string prefix, out bool allowZero)
{
prefix = ""; allowZero = false;
switch (type)
{
case 'd': case 'i':
{
long v = ParseLong(arg);
ulong mag = v < 0 ? (ulong)(-(v + 1)) + 1UL : (ulong)v;
string digits = mag.ToString(CultureInfo.InvariantCulture);
allowZero = prec < 0;
if (prec >= 0) digits = (prec == 0 && mag == 0) ? "" : digits.PadLeft(prec, '0');
prefix = v < 0 ? "-" : plus ? "+" : space ? " " : "";
return digits;
}
case 'u':
{
ulong v = ParseULong(arg);
allowZero = prec < 0;
string digits = v.ToString(CultureInfo.InvariantCulture);
if (prec >= 0) digits = (prec == 0 && v == 0) ? "" : digits.PadLeft(prec, '0');
return digits;
}
case 'o':
{
ulong v = ParseULong(arg);
string digits = (prec == 0 && v == 0) ? "" : ToBase(v, 8, false);
if (prec > 0) digits = digits.PadLeft(prec, '0');
if (alt && (digits.Length == 0 || digits[0] != '0')) digits = "0" + digits;
allowZero = prec < 0;
return digits;
}
case 'x': case 'X':
{
ulong v = ParseULong(arg);
string digits = (prec == 0 && v == 0) ? "" : ToBase(v, 16, type == 'X');
if (prec > 0) digits = digits.PadLeft(prec, '0');
if (alt && v != 0) prefix = type == 'X' ? "0X" : "0x";
allowZero = prec < 0;
return digits;
}
case 'c':
return long.TryParse(arg, NumberStyles.Integer, CultureInfo.InvariantCulture, out long code)
? ((char)code).ToString()
: (arg.Length > 0 ? arg[0].ToString() : "");
case 's':
return (prec >= 0 && arg.Length > prec) ? arg.Substring(0, prec) : arg;
case 'f': case 'F':
{
double d = ParseDouble(arg);
int p = prec < 0 ? 6 : prec;
string digits = Math.Abs(d).ToString("F" + p, CultureInfo.InvariantCulture);
if (alt && p == 0) digits += ".";
prefix = Sign(d, plus, space); allowZero = true;
return digits;
}
case 'e': case 'E':
{
double d = ParseDouble(arg);
int p = prec < 0 ? 6 : prec;
string s = FixExponent(Math.Abs(d).ToString((type == 'e' ? "e" : "E") + p, CultureInfo.InvariantCulture));
if (alt && p == 0 && !s.Contains('.'))
{
int ei = s.IndexOfAny(new[] { 'e', 'E' });
s = s.Substring(0, ei) + "." + s.Substring(ei);
}
prefix = Sign(d, plus, space); allowZero = true;
return s;
}
case 'g': case 'G':
{
double d = ParseDouble(arg);
int p = prec < 0 ? 6 : (prec == 0 ? 1 : prec);
double ad = Math.Abs(d);
string etmp = ad.ToString("E" + (p - 1), CultureInfo.InvariantCulture);
int x = ad == 0 ? 0 : int.Parse(etmp.Substring(etmp.IndexOf('E') + 1), CultureInfo.InvariantCulture);
string s;
if (x < -4 || x >= p)
{
s = ad.ToString((type == 'g' ? "e" : "E") + (p - 1), CultureInfo.InvariantCulture);
if (!alt) s = TrimGExp(s);
s = FixExponent(s);
}
else
{
int fp = Math.Max(0, p - 1 - x);
s = ad.ToString("F" + fp, CultureInfo.InvariantCulture);
if (!alt) s = TrimTrailingZeros(s);
}
prefix = Sign(d, plus, space); allowZero = true;
return s;
}
case 'a': case 'A':
{
double d = ParseDouble(arg);
prefix = Sign(d, plus, space); allowZero = true;
return HexFloat(Math.Abs(d), type == 'A', prec);
}
default:
throw new FormatException();
}
}
private static string Sign(double d, bool plus, bool space) =>
double.IsNegative(d) ? "-" : plus ? "+" : space ? " " : "";
private static string ToBase(ulong v, int b, bool upper)
{
if (v == 0) return "0";
const string digs = "0123456789abcdef";
var sb = new StringBuilder();
ulong ub = (ulong)b;
while (v > 0) { sb.Insert(0, digs[(int)(v % ub)]); v /= ub; }
return upper ? sb.ToString().ToUpperInvariant() : sb.ToString();
}
/// <summary>.NET 指數為 3 位數,C 最少 2 位數 — 修剪多餘前導 0 至最少 2 位。</summary>
private static string FixExponent(string s)
{
int ei = s.IndexOfAny(new[] { 'e', 'E' });
if (ei < 0 || ei + 2 >= s.Length) return s;
string exp = s.Substring(ei + 2).TrimStart('0');
if (exp.Length < 2) exp = exp.PadLeft(2, '0');
return s.Substring(0, ei + 2) + exp;
}
private static string TrimTrailingZeros(string s)
{
if (!s.Contains('.')) return s;
s = s.TrimEnd('0');
return s.EndsWith(".") ? s.Substring(0, s.Length - 1) : s;
}
private static string TrimGExp(string s)
{
int ei = s.IndexOfAny(new[] { 'e', 'E' });
return TrimTrailingZeros(s.Substring(0, ei)) + s.Substring(ei);
}
private static string HexFloat(double d, bool upper, int prec)
{
long bits = BitConverter.DoubleToInt64Bits(d);
int rawExp = (int)((bits >> 52) & 0x7FF);
long mant = bits & 0xFFFFFFFFFFFFFL;
string lead; int e2;
if (rawExp == 0) { lead = "0"; e2 = mant == 0 ? 0 : -1022; }
else { lead = "1"; e2 = rawExp - 1023; }
string frac = mant.ToString("x13", CultureInfo.InvariantCulture);
if (prec >= 0) frac = prec < frac.Length ? frac.Substring(0, prec) : frac.PadRight(prec, '0');
else frac = frac.TrimEnd('0');
string body = "0x" + lead + (frac.Length > 0 ? "." + frac : "") + "p" + (e2 >= 0 ? "+" : "-") + Math.Abs(e2);
return upper ? body.ToUpperInvariant() : body;
}
private static string TokenizeArgsNext(string s, ref int i, int n)
{
var sb = new StringBuilder();
if (s[i] == '\'' || s[i] == '"')
{
char q = s[i++];
sb.Append(q);
while (i < n && s[i] != q) sb.Append(s[i++]);
if (i < n) sb.Append(s[i++]);
}
else while (i < n && !char.IsWhiteSpace(s[i])) sb.Append(s[i++]);
return sb.ToString();
}
private static List<string> TokenizeArgs(string s)
{
var list = new List<string>();
int i = 0, n = s.Length;
while (i < n)
{
while (i < n && char.IsWhiteSpace(s[i])) i++;
if (i >= n) break;
list.Add(TokenizeArgsNext(s, ref i, n));
}
return list;
}
private static string StripQuotes(string s) =>
s.Length >= 2 && (s[0] == '\'' || s[0] == '"') && s[^1] == s[0]
? s.Substring(1, s.Length - 2) : s;
private static string NextArg(IReadOnlyList<string> args, ref int ai)
{
if (ai >= args.Count) throw new ArgumentException("not enough arguments");
return args[ai++];
}
private static long ParseLong(string s)
{
s = s.Trim();
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out long v)) return v;
if ((s.StartsWith("0x") || s.StartsWith("0X")) &&
long.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out long h)) return h;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return (long)d;
throw new ArgumentException("invalid integer argument");
}
private static ulong ParseULong(string s)
{
long v = ParseLong(s);
return unchecked((ulong)v);
}
private static double ParseDouble(string s)
{
if (double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return d;
throw new ArgumentException("invalid floating-point argument");
}
#endregion
#region Helpers
private static string GetCommand(string line)