fix(focas): decode all four bytes of an axis/spindle name record, up to the first NUL
ReadNameRecord read only the first 2 bytes of each 4-byte 0x0089/0x008A record, so the real 31i-B's paired axes 'AA6'/'AA7' (live capture ref-axisname-0x0089.bin) both decoded as 'AA' -- the 'Makino with two AA axes' that #548 then had to disambiguate as AA_1/AA_2. The name is now the record's ASCII up to its FIRST NUL (not trailing-NUL trimming), so buffer residue such as Z-34184's spindle record 53 31 00 4e ('S1' + an 'N' left from an earlier 'CNC') is discarded. Client-visible: on such a machine Axes/AA_1 and Axes/AA_2 become Axes/AA6 and Axes/AA7 (the old names were a decode artefact). #548's disambiguation stays as the guard for genuinely duplicate names. Ported from the sister ignitionoee FOCAS driver (Focas.Wire-001, Focas.Wire-022 / v0.18.1).
This commit is contained in:
@@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// config redeploy).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
|
||||
/// displays are at most two characters — name + suffix — so they cannot contain <c>_</c>),
|
||||
/// but the generator still checks: a generated segment that would collide with anything
|
||||
/// else in the set escalates its ordinal until unique, so the output is always
|
||||
/// collision-free whatever the input.
|
||||
/// Suffix collisions with genuine names are not expected (a FOCAS name record is at most four
|
||||
/// ASCII characters and controls do not use <c>_</c>), but the generator still checks: a
|
||||
/// generated segment that would collide with anything else in the set escalates its ordinal
|
||||
/// until unique, so the output is always collision-free whatever the input.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Note on the Makino that motivated this.</b> Its two "AA" axes were a decode artefact:
|
||||
/// the name records actually read <c>"AA6"</c> and <c>"AA7"</c>, and the wire layer read only
|
||||
/// the first two bytes of each 4-byte record. With the full record decoded
|
||||
/// (<see cref="Wire.FocasWireProtocol.ReadNameRecord"/>) that machine reports unique names and
|
||||
/// its axes publish bare (<c>Axes/AA6</c>, <c>Axes/AA7</c>). This disambiguation remains as the
|
||||
/// guard for a control that genuinely reports duplicates.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FixedTreeNames
|
||||
|
||||
@@ -1114,7 +1114,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
return alarms;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||||
/// <summary>
|
||||
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
|
||||
/// each named by <see cref="FocasWireProtocol.ReadNameRecord"/>. Blank records are skipped but
|
||||
/// keep their slot's 1-based index.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The record type the factory builds.</typeparam>
|
||||
/// <param name="payload">The response-block payload.</param>
|
||||
/// <param name="maxCount">Maximum number of records to inspect.</param>
|
||||
/// <param name="factory">Builds one record from its 1-based index and name.</param>
|
||||
/// <returns>The non-blank records, in wire order.</returns>
|
||||
internal static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||||
{
|
||||
var names = new List<T>();
|
||||
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
|
||||
|
||||
@@ -239,19 +239,34 @@ internal static class FocasWireProtocol
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
|
||||
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
|
||||
/// <c>"X"</c>.
|
||||
/// Read one 4-byte axis/spindle name record: the ASCII up to (not including) the record's
|
||||
/// FIRST NUL, at most 4 bytes, with trailing spaces trimmed — <c>"X \0\0"</c> → <c>"X"</c>,
|
||||
/// <c>"AA6\0"</c> → <c>"AA6"</c>, <c>"S1\0N"</c> → <c>"S1"</c>.
|
||||
/// </summary>
|
||||
/// <param name="bytes">The bytes to decode.</param>
|
||||
/// <returns>The decoded name record.</returns>
|
||||
/// <remarks>
|
||||
/// <para><b>All four bytes are name bytes.</b> This used to read only the first two, which
|
||||
/// collapsed the real 31i-B's paired axes <c>"AA6"</c>/<c>"AA7"</c> (live capture
|
||||
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate Gitea
|
||||
/// #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <para><b>The first NUL terminates, not trailing-NUL trimming.</b> A control writes only the
|
||||
/// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
|
||||
/// <c>cnc_rdspdlname</c> answered <c>53 31 00 4e</c> — <c>"S1"</c> plus the <c>'N'</c> of an
|
||||
/// earlier <c>"CNC"</c> response. Both rules are ported from the sister ignitionoee FOCAS
|
||||
/// driver (Focas.Wire-001 / Focas.Wire-022).</para>
|
||||
/// </remarks>
|
||||
/// <param name="bytes">The record bytes (4 on the wire; a shorter span is read as far as it goes).</param>
|
||||
/// <returns>The decoded name; empty for a blank record.</returns>
|
||||
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 2) return string.Empty;
|
||||
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
|
||||
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
|
||||
var record = bytes.Slice(0, Math.Min(NameRecordLength, bytes.Length));
|
||||
var nul = record.IndexOf((byte)0);
|
||||
if (nul >= 0) record = record.Slice(0, nul);
|
||||
return Encoding.ASCII.GetString(record).TrimEnd(' ');
|
||||
}
|
||||
|
||||
/// <summary>Byte length of one axis/spindle name record.</summary>
|
||||
public const int NameRecordLength = 4;
|
||||
|
||||
private static byte[] BuildRequestBlock(RequestBlock request)
|
||||
{
|
||||
var extra = request.ExtraPayload ?? Array.Empty<byte>();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Axis-name (0x0089) / spindle-name (0x008A) record decode. Each record is 4 bytes; the name is
|
||||
/// the ASCII up to the record's FIRST NUL. Ported from the sister ignitionoee FOCAS driver
|
||||
/// (Focas.Wire-001 and Focas.Wire-022, v0.18.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Reading only 2 bytes collides real names.</b> The live 31i-B capture
|
||||
/// (<c>ref-axisname-0x0089.bin</c>) carries <c>"AA6"</c> and <c>"AA7"</c>; the old 2-byte read
|
||||
/// turned both into <c>"AA"</c>, which is exactly the "one observed Makino returns two axis
|
||||
/// folders both called AA" that Gitea #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <para><b>Trailing-only trimming keeps buffer residue.</b> A control writes only the bytes the
|
||||
/// name needs; on Makino Z-34184 (2026-08-19) <c>cnc_rdspdlname</c> came back
|
||||
/// <c>53 31 00 4e</c> — <c>"S1"</c> then the <c>'N'</c> of an earlier <c>"CNC"</c>. The name
|
||||
/// ends at the first NUL.</para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasNameRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_live_axis_name_capture_keeps_three_character_names_distinct()
|
||||
{
|
||||
var payload = WireFixtures.Payload("ref-axisname-0x0089.bin", 0x0089);
|
||||
|
||||
var names = FocasWireClient.ReadNameRecords(payload, 32, (index, name) => (index, name));
|
||||
|
||||
names.Select(n => n.name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
|
||||
names.Select(n => n.index).ShouldBe([(short)1, 2, 3, 4, 5, 6, 7]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_live_spindle_name_capture_decodes()
|
||||
{
|
||||
var payload = WireFixtures.Payload("ref-spdlname-0x008a.bin", 0x008a);
|
||||
|
||||
FocasWireClient.ReadNameRecords(payload, 8, (_, name) => name).ShouldBe(["S1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_name_ends_at_its_first_NUL_so_residue_after_it_is_discarded()
|
||||
{
|
||||
// The Z-34184 spindle-name record: "S1", NUL, then an 'N' left over from a previous "CNC".
|
||||
FocasWireProtocol.ReadNameRecord([0x53, 0x31, 0x00, 0x4e]).ShouldBe("S1");
|
||||
// A single-character name with residue in BOTH trailing bytes.
|
||||
FocasWireProtocol.ReadNameRecord([0x58, 0x00, 0x43, 0x4e]).ShouldBe("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_four_record_bytes_are_name_bytes()
|
||||
{
|
||||
FocasWireProtocol.ReadNameRecord("AA6\0"u8.ToArray()).ShouldBe("AA6");
|
||||
FocasWireProtocol.ReadNameRecord("ABCD"u8.ToArray()).ShouldBe("ABCD");
|
||||
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_record_that_starts_with_NUL_is_blank()
|
||||
{
|
||||
FocasWireProtocol.ReadNameRecord([0x00, 0x41, 0x41, 0x00]).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Servo_meter_names_come_out_whole_too()
|
||||
{
|
||||
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
|
||||
var sv = blocks.First(b => b.Command == 0x0056).Payload;
|
||||
var names = blocks.First(b => b.Command == 0x0089).Payload;
|
||||
|
||||
var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32);
|
||||
|
||||
meters.Take(7).Select(m => m.Name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,12 @@ public sealed class FocasWireProtocolTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadNameRecord_reads_two_bytes_and_strips_padding()
|
||||
public void ReadNameRecord_strips_padding()
|
||||
{
|
||||
// Full record semantics (first NUL, all four bytes) are pinned in FocasNameRecordTests.
|
||||
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
|
||||
FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY");
|
||||
FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes
|
||||
FocasWireProtocol.ReadNameRecord(ReadOnlySpan<byte>.Empty).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
Reference in New Issue
Block a user