Compare commits
5 Commits
627c17fae1
...
3a8f2bed4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a8f2bed4e | |||
| b7f29f3048 | |||
| 0702551c25 | |||
| db9c68ca9c | |||
| 95b5b09a67 |
@@ -168,6 +168,30 @@ the unchanged elements included. For example, to change 2 elements of a
|
||||
the 2 new ones). Sending only the 2 changed values overwrites the attribute
|
||||
with a 2-element array.
|
||||
|
||||
When only a few indices need changing and the rest should be reset to the
|
||||
element type's default, use `WriteArrayElementsAsync` instead of building the
|
||||
full array manually:
|
||||
|
||||
```csharp
|
||||
await session.WriteArrayElementsAsync(
|
||||
serverHandle, itemHandle,
|
||||
elementDataType: MxDataType.Integer,
|
||||
totalLength: 20,
|
||||
elements: new Dictionary<uint, MxValue>
|
||||
{
|
||||
[2] = 42.ToMxValue(),
|
||||
[7] = 99.ToMxValue(),
|
||||
});
|
||||
```
|
||||
|
||||
The gateway expands the sparse descriptor into a full `totalLength`-element
|
||||
array before forwarding to the worker. Indices not listed in `elements` are
|
||||
written as the element type's default — this is a **reset**, not a preserve;
|
||||
current values at those positions are discarded. `totalLength` is required and
|
||||
must match the declared length of the array attribute. Bare-name array items
|
||||
(`Area001.Pump001.Speed`) are auto-normalized to the `[]` form at `AddItem`
|
||||
so the array attribute accepts the write.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
The test CLI supports deterministic JSON output for automation:
|
||||
|
||||
@@ -303,6 +303,69 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildSparseArray produces a SparseArrayValue MxValue with the correct total length and elements.</summary>
|
||||
[Fact]
|
||||
public void BuildSparseArray_ProducesSparseArrayValueWithCorrectTotalLengthAndElements()
|
||||
{
|
||||
MxValue element0 = 42.ToMxValue();
|
||||
MxValue element3 = 99.ToMxValue();
|
||||
Dictionary<uint, MxValue> elements = new()
|
||||
{
|
||||
[0u] = element0,
|
||||
[3u] = element3,
|
||||
};
|
||||
|
||||
MxValue result = MxGatewaySession.BuildSparseArray(MxDataType.Integer, totalLength: 10, elements);
|
||||
|
||||
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, result.KindCase);
|
||||
Assert.Equal(10u, result.SparseArrayValue.TotalLength);
|
||||
Assert.Equal(MxDataType.Integer, result.SparseArrayValue.ElementDataType);
|
||||
Assert.Equal(2, result.SparseArrayValue.Elements.Count);
|
||||
|
||||
MxSparseElement el0 = Assert.Single(result.SparseArrayValue.Elements, e => e.Index == 0u);
|
||||
Assert.Same(element0, el0.Value);
|
||||
|
||||
MxSparseElement el3 = Assert.Single(result.SparseArrayValue.Elements, e => e.Index == 3u);
|
||||
Assert.Same(element3, el3.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteArrayElementsAsync builds a write command whose value is a SparseArrayValue.</summary>
|
||||
[Fact]
|
||||
public async Task WriteArrayElementsAsync_BuildsWriteCommandWithSparseArrayValue()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Write,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
Dictionary<uint, MxValue> elements = new() { [1u] = 7.ToMxValue() };
|
||||
|
||||
await session.WriteArrayElementsAsync(
|
||||
serverHandle: 12,
|
||||
itemHandle: 34,
|
||||
elementDataType: MxDataType.Integer,
|
||||
totalLength: 5,
|
||||
elements: elements,
|
||||
userId: 56);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.Write.ServerHandle);
|
||||
Assert.Equal(34, request.Command.Write.ItemHandle);
|
||||
Assert.Equal(56, request.Command.Write.UserId);
|
||||
MxValue written = request.Command.Write.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, written.KindCase);
|
||||
Assert.Equal(5u, written.SparseArrayValue.TotalLength);
|
||||
Assert.Equal(MxDataType.Integer, written.SparseArrayValue.ElementDataType);
|
||||
MxSparseElement el = Assert.Single(written.SparseArrayValue.Elements);
|
||||
Assert.Equal(1u, el.Index);
|
||||
Assert.Equal(7, el.Value.Int32Value);
|
||||
}
|
||||
|
||||
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||
{
|
||||
return new MxGatewayClient(transport.Options, transport);
|
||||
|
||||
@@ -687,6 +687,63 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes specific array indices to an item using default-fill semantics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The gateway expands the sparse descriptor into a full <c>totalLength</c>-element array
|
||||
/// before forwarding to the worker. Indices not listed in <paramref name="elements"/> are
|
||||
/// written as the element type's default value — this is a RESET, not a preserve. The
|
||||
/// current values at those positions are discarded. <paramref name="totalLength"/> is
|
||||
/// required and must match the declared length of the array attribute.
|
||||
/// </remarks>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="elementDataType">The MXAccess data type of each element.</param>
|
||||
/// <param name="totalLength">The total declared length of the target array attribute.</param>
|
||||
/// <param name="elements">Map of zero-based array index to scalar <see cref="MxValue"/>.</param>
|
||||
/// <param name="userId">User ID context for the write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task WriteArrayElementsAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxDataType elementDataType,
|
||||
uint totalLength,
|
||||
IReadOnlyDictionary<uint, MxValue> elements,
|
||||
int userId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
MxValue value = BuildSparseArray(elementDataType, totalLength, elements);
|
||||
return WriteAsync(serverHandle, itemHandle, value, userId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="MxValue"/> whose <see cref="MxValue.SparseArrayValue"/> describes a
|
||||
/// default-fill partial array write.
|
||||
/// </summary>
|
||||
/// <param name="elementDataType">The MXAccess data type of each element.</param>
|
||||
/// <param name="totalLength">The total declared length of the target array attribute.</param>
|
||||
/// <param name="elements">Map of zero-based array index to scalar <see cref="MxValue"/>.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <see cref="MxValue.KindOneofCase.SparseArrayValue"/> set.</returns>
|
||||
internal static MxValue BuildSparseArray(
|
||||
MxDataType elementDataType,
|
||||
uint totalLength,
|
||||
IReadOnlyDictionary<uint, MxValue> elements)
|
||||
{
|
||||
MxSparseArray sparse = new()
|
||||
{
|
||||
ElementDataType = elementDataType,
|
||||
TotalLength = totalLength,
|
||||
};
|
||||
foreach (KeyValuePair<uint, MxValue> kv in elements)
|
||||
{
|
||||
sparse.Elements.Add(new MxSparseElement { Index = kv.Key, Value = kv.Value });
|
||||
}
|
||||
|
||||
return new MxValue { SparseArrayValue = sparse };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value to an item on the MXAccess server without error checking.
|
||||
/// </summary>
|
||||
|
||||
@@ -145,6 +145,18 @@ the unchanged elements included. For example, to change 2 elements of a
|
||||
the 2 new ones). Sending only the 2 changed values overwrites the attribute
|
||||
with a 2-element array.
|
||||
|
||||
`Session.WriteArrayElements` offers a default-fill shorthand: pass only the
|
||||
indices you want to set along with a `totalLength`. The gateway expands the
|
||||
sparse representation into a full array before forwarding to MXAccess — every
|
||||
unmentioned index receives the element type's zero value (boolean `false`,
|
||||
integer `0`, float `0.0`, string `""`, time = Unix epoch). This is a **RESET**
|
||||
of unmentioned indices, not a preserve of existing values. Use the full-array
|
||||
form (read-modify-write) when existing element values must be preserved.
|
||||
|
||||
`AddItem` (and `AddItem2`) now auto-normalize a bare attribute name to the `[]`
|
||||
array address form expected by MXAccess, so callers do not need to append `[]`
|
||||
themselves. Both forms are accepted; duplicates are deduplicated by the gateway.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -666,3 +666,101 @@ func authorizationFromContext(ctx context.Context) string {
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WriteArrayElements / buildSparseArrayValue unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSparseArrayValueSetsSparseOneof(t *testing.T) {
|
||||
elements := map[uint32]*MxValue{
|
||||
2: Int32Value(99),
|
||||
0: Int32Value(10),
|
||||
}
|
||||
v := buildSparseArrayValue(DataTypeInteger, 5, elements)
|
||||
|
||||
sa, ok := v.Kind.(*pb.MxValue_SparseArrayValue)
|
||||
if !ok {
|
||||
t.Fatalf("Kind is %T, want *pb.MxValue_SparseArrayValue", v.Kind)
|
||||
}
|
||||
got := sa.SparseArrayValue
|
||||
if got.GetElementDataType() != DataTypeInteger {
|
||||
t.Errorf("ElementDataType = %v, want DataTypeInteger", got.GetElementDataType())
|
||||
}
|
||||
if got.GetTotalLength() != 5 {
|
||||
t.Errorf("TotalLength = %d, want 5", got.GetTotalLength())
|
||||
}
|
||||
if len(got.GetElements()) != 2 {
|
||||
t.Fatalf("len(Elements) = %d, want 2", len(got.GetElements()))
|
||||
}
|
||||
// Elements must be sorted by index (ascending).
|
||||
if got.GetElements()[0].GetIndex() != 0 {
|
||||
t.Errorf("Elements[0].Index = %d, want 0", got.GetElements()[0].GetIndex())
|
||||
}
|
||||
if got.GetElements()[0].GetValue().GetInt32Value() != 10 {
|
||||
t.Errorf("Elements[0].Value = %v, want 10", got.GetElements()[0].GetValue())
|
||||
}
|
||||
if got.GetElements()[1].GetIndex() != 2 {
|
||||
t.Errorf("Elements[1].Index = %d, want 2", got.GetElements()[1].GetIndex())
|
||||
}
|
||||
if got.GetElements()[1].GetValue().GetInt32Value() != 99 {
|
||||
t.Errorf("Elements[1].Value = %v, want 99", got.GetElements()[1].GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSparseArrayValueEmptyMapProducesEmptyElements(t *testing.T) {
|
||||
v := buildSparseArrayValue(DataTypeBoolean, 4, map[uint32]*MxValue{})
|
||||
|
||||
sa, ok := v.Kind.(*pb.MxValue_SparseArrayValue)
|
||||
if !ok {
|
||||
t.Fatalf("Kind is %T, want *pb.MxValue_SparseArrayValue", v.Kind)
|
||||
}
|
||||
if len(sa.SparseArrayValue.GetElements()) != 0 {
|
||||
t.Errorf("len(Elements) = %d, want 0", len(sa.SparseArrayValue.GetElements()))
|
||||
}
|
||||
if sa.SparseArrayValue.GetTotalLength() != 4 {
|
||||
t.Errorf("TotalLength = %d, want 4", sa.SparseArrayValue.GetTotalLength())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArrayElementsSendsWriteCommandWithSparseOneof(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
err := session.WriteArrayElements(
|
||||
context.Background(),
|
||||
1, 2,
|
||||
DataTypeFloat,
|
||||
10,
|
||||
map[uint32]*MxValue{3: FloatValue(1.5)},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteArrayElements() error = %v", err)
|
||||
}
|
||||
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE {
|
||||
t.Fatalf("command kind = %s, want WRITE", cmd.GetKind())
|
||||
}
|
||||
val := cmd.GetWrite().GetValue()
|
||||
sa, ok := val.Kind.(*pb.MxValue_SparseArrayValue)
|
||||
if !ok {
|
||||
t.Fatalf("value kind is %T, want *pb.MxValue_SparseArrayValue", val.Kind)
|
||||
}
|
||||
if sa.SparseArrayValue.GetTotalLength() != 10 {
|
||||
t.Errorf("TotalLength = %d, want 10", sa.SparseArrayValue.GetTotalLength())
|
||||
}
|
||||
if sa.SparseArrayValue.GetElementDataType() != DataTypeFloat {
|
||||
t.Errorf("ElementDataType = %v, want DataTypeFloat", sa.SparseArrayValue.GetElementDataType())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -580,6 +581,57 @@ func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32,
|
||||
})
|
||||
}
|
||||
|
||||
// WriteArrayElements writes a sparse, default-filled array: only the given
|
||||
// elements (index → scalar value) are set; every unmentioned index up to
|
||||
// totalLength is written as the element type's default (false / 0 / "" / Unix
|
||||
// epoch for time). The gateway expands the sparse representation into a full
|
||||
// array write before forwarding to MXAccess — this is a RESET of unmentioned
|
||||
// indices, not a preserve. Neither RESET semantics nor the original array
|
||||
// content are retained.
|
||||
//
|
||||
// elementDataType must be a scalar MXAccess type (Boolean, Integer, Float,
|
||||
// Double, String, or Time). totalLength must be at least as large as the
|
||||
// highest index in elements plus one.
|
||||
func (s *Session) WriteArrayElements(
|
||||
ctx context.Context,
|
||||
serverHandle, itemHandle int32,
|
||||
elementDataType MxDataType,
|
||||
totalLength uint32,
|
||||
elements map[uint32]*MxValue,
|
||||
userID int32,
|
||||
) error {
|
||||
return s.Write(ctx, serverHandle, itemHandle, buildSparseArrayValue(elementDataType, totalLength, elements), userID)
|
||||
}
|
||||
|
||||
// buildSparseArrayValue constructs the MxValue carrying an MxSparseArray oneof
|
||||
// arm from a map of index → scalar MxValue. Keys are visited in ascending
|
||||
// order so the produced slice is deterministic (important for test assertions).
|
||||
func buildSparseArrayValue(elementDataType MxDataType, totalLength uint32, elements map[uint32]*MxValue) *MxValue {
|
||||
indices := make([]uint32, 0, len(elements))
|
||||
for idx := range elements {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] })
|
||||
|
||||
sparseElements := make([]*MxSparseElement, 0, len(elements))
|
||||
for _, idx := range indices {
|
||||
sparseElements = append(sparseElements, &MxSparseElement{
|
||||
Index: idx,
|
||||
Value: elements[idx],
|
||||
})
|
||||
}
|
||||
|
||||
return &MxValue{
|
||||
Kind: &pb.MxValue_SparseArrayValue{
|
||||
SparseArrayValue: &MxSparseArray{
|
||||
ElementDataType: elementDataType,
|
||||
TotalLength: totalLength,
|
||||
Elements: sparseElements,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PingRaw sends a diagnostic PING command and returns the raw reply.
|
||||
// The message is echoed back by the gateway in the reply's DiagnosticMessage field.
|
||||
func (s *Session) PingRaw(ctx context.Context, message string) (*MxCommandReply, error) {
|
||||
|
||||
@@ -36,6 +36,13 @@ type (
|
||||
Value = pb.MxValue
|
||||
// MxArray is the protobuf representation of an MXAccess array value.
|
||||
MxArray = pb.MxArray
|
||||
// MxSparseArray is the write-only protobuf type for default-fill partial
|
||||
// array writes. The gateway expands it to a full array before forwarding
|
||||
// to MXAccess: unmentioned indices receive the element type's default value
|
||||
// (boolean false, integer 0, float 0.0, string "", time = Unix epoch).
|
||||
MxSparseArray = pb.MxSparseArray
|
||||
// MxSparseElement is one index/value pair inside an MxSparseArray.
|
||||
MxSparseElement = pb.MxSparseElement
|
||||
// MxStatusProxy mirrors the MXAccess MXSTATUS_PROXY structure.
|
||||
MxStatusProxy = pb.MxStatusProxy
|
||||
// ProtocolStatus is the gateway-level status carried on every reply.
|
||||
|
||||
@@ -148,6 +148,33 @@ the unchanged elements included. For example, to change 2 elements of a
|
||||
the 2 new ones). Sending only the 2 changed values overwrites the attribute
|
||||
with a 2-element array.
|
||||
|
||||
### Default-fill partial array writes
|
||||
|
||||
`Session.write_array_elements` lets you write only the indices you care about.
|
||||
The gateway fills every unmentioned position with the type default for the
|
||||
declared `element_data_type` (0, `False`, `""`, Unix epoch for timestamps).
|
||||
The previous value at those positions is **not** preserved — the gateway expands
|
||||
the sparse map to a full array before forwarding the write to MXAccess, so this
|
||||
is still a full replacement:
|
||||
|
||||
```python
|
||||
# Write indices 0 and 5 of a 10-element integer array.
|
||||
# Positions 1-4 and 6-9 become 0, not their previous values.
|
||||
await session.write_array_elements(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
element_data_type=pb.MX_DATA_TYPE_INTEGER,
|
||||
total_length=10,
|
||||
elements={0: 100, 5: 500},
|
||||
)
|
||||
```
|
||||
|
||||
Bare-name array items (e.g. `Object.ArrayAttr` without an index suffix) added
|
||||
via `add_item` auto-normalize to `[]` — they refer to the whole array, not a
|
||||
single element. Writes through such handles must cover the full array or use
|
||||
`write_array_elements` to supply `total_length` and let the gateway fill
|
||||
defaults for the rest.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
The `GalaxyRepositoryClient` wraps the read-only `GalaxyRepository` gRPC
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: galaxy_repository.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
# Protobuf Python Version: 6.33.5
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
33,
|
||||
5,
|
||||
'',
|
||||
'galaxy_repository.proto'
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import warnings
|
||||
|
||||
import galaxy_repository_pb2 as galaxy__repository__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_GENERATED_VERSION = '1.81.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
@@ -25,7 +25,7 @@ if _version_not_supported:
|
||||
)
|
||||
|
||||
|
||||
class GalaxyRepositoryStub(object):
|
||||
class GalaxyRepositoryStub:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
@@ -72,7 +72,7 @@ class GalaxyRepositoryStub(object):
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class GalaxyRepositoryServicer(object):
|
||||
class GalaxyRepositoryServicer:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
@@ -162,7 +162,7 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class GalaxyRepository(object):
|
||||
class GalaxyRepository:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import warnings
|
||||
|
||||
import mxaccess_gateway_pb2 as mxaccess__gateway__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_GENERATED_VERSION = '1.81.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
@@ -25,7 +25,7 @@ if _version_not_supported:
|
||||
)
|
||||
|
||||
|
||||
class MxAccessGatewayStub(object):
|
||||
class MxAccessGatewayStub:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
@@ -78,7 +78,7 @@ class MxAccessGatewayStub(object):
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class MxAccessGatewayServicer(object):
|
||||
class MxAccessGatewayServicer:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
@@ -189,7 +189,7 @@ def add_MxAccessGatewayServicer_to_server(servicer, server):
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class MxAccessGateway(object):
|
||||
class MxAccessGateway:
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: mxaccess_worker.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
# Protobuf Python Version: 6.33.5
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
33,
|
||||
5,
|
||||
'',
|
||||
'mxaccess_worker.proto'
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import grpc
|
||||
import warnings
|
||||
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_GENERATED_VERSION = '1.81.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
|
||||
@@ -489,6 +489,56 @@ class Session:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def write_array_elements(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
element_data_type: "pb.MxDataType.ValueType",
|
||||
total_length: int,
|
||||
elements: dict[int, MxValueInput],
|
||||
*,
|
||||
user_id: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Write a partial array by specifying only the indices you want to set.
|
||||
|
||||
The gateway expands the sparse representation into a full ``total_length``
|
||||
array before forwarding the write to MXAccess. Indices not listed in
|
||||
*elements* are filled with the type default for *element_data_type* (0,
|
||||
False, empty string, Unix epoch for timestamps, etc.). The previous
|
||||
value at those positions is **not** preserved — this is a full array
|
||||
replacement, not a patch.
|
||||
|
||||
Args:
|
||||
server_handle: Handle returned by :meth:`register`.
|
||||
item_handle: Handle returned by :meth:`add_item`.
|
||||
element_data_type: ``pb.MX_DATA_TYPE_*`` enum value for the scalar
|
||||
element type of the target array attribute.
|
||||
total_length: Total number of elements in the written array. Must
|
||||
be > 0 and large enough to contain every index in *elements*.
|
||||
elements: Mapping of zero-based element index to scalar value.
|
||||
Values are converted with :func:`~zb_mom_ww_mxgateway.values.to_mx_value`.
|
||||
user_id: Galaxy user id to stamp on the write (requires a prior
|
||||
supervisory advise to take effect — see README).
|
||||
correlation_id: Optional client-supplied correlation token echoed
|
||||
in the command reply.
|
||||
"""
|
||||
sparse = pb.MxSparseArray(
|
||||
element_data_type=element_data_type,
|
||||
total_length=total_length,
|
||||
elements=[
|
||||
pb.MxSparseElement(index=idx, value=to_mx_value(val))
|
||||
for idx, val in elements.items()
|
||||
],
|
||||
)
|
||||
await self.write(
|
||||
server_handle,
|
||||
item_handle,
|
||||
pb.MxValue(sparse_array_value=sparse),
|
||||
user_id=user_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def write2(
|
||||
self,
|
||||
server_handle: int,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for Session.write_array_elements default-fill sparse-array helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sparse_mx_value(
|
||||
element_data_type: "pb.MxDataType.ValueType",
|
||||
total_length: int,
|
||||
elements: dict[int, Any],
|
||||
) -> pb.MxValue:
|
||||
"""Build an MxValue wrapping an MxSparseArray from Python primitives.
|
||||
|
||||
Mirrors the logic inside Session.write_array_elements so tests can assert
|
||||
the exact wire shape the helper produces without going through the full
|
||||
gRPC stack.
|
||||
"""
|
||||
from zb_mom_ww_mxgateway.values import to_mx_value
|
||||
|
||||
return pb.MxValue(
|
||||
sparse_array_value=pb.MxSparseArray(
|
||||
element_data_type=element_data_type,
|
||||
total_length=total_length,
|
||||
elements=[
|
||||
pb.MxSparseElement(index=idx, value=to_mx_value(val))
|
||||
for idx, val in elements.items()
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake stub (minimal — only needs Invoke / OpenSession)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = list(replies)
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class _FakeStub:
|
||||
"""Minimal stub that satisfies GatewayClient for a single invoke round-trip."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
ok = pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK)
|
||||
self.open_session = _FakeUnary([pb.OpenSessionReply(session_id="s1", protocol_status=ok)])
|
||||
self.invoke = _FakeUnary(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="s1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=ok,
|
||||
),
|
||||
]
|
||||
)
|
||||
self.OpenSession = self.open_session
|
||||
self.Invoke = self.invoke
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sparse_mx_value_builder_sets_correct_oneof() -> None:
|
||||
"""Builder helper must produce an MxValue with kind == 'sparse_array_value'."""
|
||||
mv = _make_sparse_mx_value(pb.MX_DATA_TYPE_INTEGER, 5, {0: 10, 3: 30})
|
||||
assert mv.WhichOneof("kind") == "sparse_array_value"
|
||||
|
||||
|
||||
def test_sparse_mx_value_builder_total_length() -> None:
|
||||
"""total_length must equal the value passed to the builder."""
|
||||
mv = _make_sparse_mx_value(pb.MX_DATA_TYPE_INTEGER, 20, {1: 7})
|
||||
assert mv.sparse_array_value.total_length == 20
|
||||
|
||||
|
||||
def test_sparse_mx_value_builder_element_count_and_values() -> None:
|
||||
"""Elements list length and scalar values must match the input dict."""
|
||||
mv = _make_sparse_mx_value(pb.MX_DATA_TYPE_INTEGER, 10, {0: 11, 4: 55, 9: 99})
|
||||
sa = mv.sparse_array_value
|
||||
assert len(sa.elements) == 3
|
||||
by_index = {e.index: e.value for e in sa.elements}
|
||||
assert by_index[0].int32_value == 11
|
||||
assert by_index[4].int32_value == 55
|
||||
assert by_index[9].int32_value == 99
|
||||
|
||||
|
||||
def test_sparse_mx_value_builder_element_data_type() -> None:
|
||||
"""element_data_type must be forwarded verbatim."""
|
||||
mv = _make_sparse_mx_value(pb.MX_DATA_TYPE_FLOAT, 3, {})
|
||||
assert mv.sparse_array_value.element_data_type == pb.MX_DATA_TYPE_FLOAT
|
||||
|
||||
|
||||
def test_sparse_mx_value_builder_empty_elements() -> None:
|
||||
"""An empty elements dict must still produce a valid MxSparseArray."""
|
||||
mv = _make_sparse_mx_value(pb.MX_DATA_TYPE_BOOLEAN, 8, {})
|
||||
sa = mv.sparse_array_value
|
||||
assert len(sa.elements) == 0
|
||||
assert sa.total_length == 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration-level: write_array_elements routes through Session.write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_array_elements_sends_sparse_array_write_command() -> None:
|
||||
"""write_array_elements must send a WRITE command whose value is sparse_array_value."""
|
||||
stub = _FakeStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
await session.write_array_elements(
|
||||
server_handle=1,
|
||||
item_handle=2,
|
||||
element_data_type=pb.MX_DATA_TYPE_INTEGER,
|
||||
total_length=10,
|
||||
elements={0: 100, 5: 500},
|
||||
)
|
||||
|
||||
assert len(stub.invoke.requests) == 1
|
||||
cmd_req: pb.MxCommandRequest = stub.invoke.requests[0]
|
||||
cmd = cmd_req.command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE
|
||||
mv = cmd.write.value
|
||||
assert mv.WhichOneof("kind") == "sparse_array_value"
|
||||
|
||||
sa = mv.sparse_array_value
|
||||
assert sa.element_data_type == pb.MX_DATA_TYPE_INTEGER
|
||||
assert sa.total_length == 10
|
||||
assert len(sa.elements) == 2
|
||||
by_index = {e.index: e.value for e in sa.elements}
|
||||
assert by_index[0].int32_value == 100
|
||||
assert by_index[5].int32_value == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_array_elements_forwards_user_id() -> None:
|
||||
"""user_id must reach the WriteCommand."""
|
||||
stub = _FakeStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
await session.write_array_elements(
|
||||
server_handle=1,
|
||||
item_handle=2,
|
||||
element_data_type=pb.MX_DATA_TYPE_BOOLEAN,
|
||||
total_length=4,
|
||||
elements={},
|
||||
user_id=42,
|
||||
)
|
||||
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.write.user_id == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_array_elements_string_elements() -> None:
|
||||
"""String element values must be encoded as string_value scalars."""
|
||||
stub = _FakeStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
await session.write_array_elements(
|
||||
server_handle=1,
|
||||
item_handle=2,
|
||||
element_data_type=pb.MX_DATA_TYPE_STRING,
|
||||
total_length=3,
|
||||
elements={1: "hello", 2: "world"},
|
||||
)
|
||||
|
||||
sa = stub.invoke.requests[0].command.write.value.sparse_array_value
|
||||
by_index = {e.index: e.value for e in sa.elements}
|
||||
assert by_index[1].string_value == "hello"
|
||||
assert by_index[2].string_value == "world"
|
||||
@@ -168,6 +168,39 @@ the unchanged elements included. For example, to change 2 elements of a
|
||||
the 2 new ones). Sending only the 2 changed values overwrites the attribute
|
||||
with a 2-element array.
|
||||
|
||||
#### Default-fill partial array writes
|
||||
|
||||
When you only need to set a handful of indices and want every other position to
|
||||
take the element type's default (zero / `false` / empty string / Unix epoch for
|
||||
timestamps), use `Session::write_array_elements` instead:
|
||||
|
||||
```rust
|
||||
// Write a 10-element integer array; index 0 = 42, index 7 = 99,
|
||||
// all other indices default to 0 (not preserved from the previous value).
|
||||
session
|
||||
.write_array_elements(
|
||||
server_handle,
|
||||
item_handle,
|
||||
MxDataType::Integer,
|
||||
10,
|
||||
[(0, MxValue::int32(42)), (7, MxValue::int32(99))],
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
The gateway expands the sparse representation into a full `MxArray` before
|
||||
forwarding to the worker — the worker and MXAccess COM never see the sparse
|
||||
form. Unmentioned indices are reset to the type default, **not** preserved from
|
||||
the existing attribute value.
|
||||
|
||||
#### Bare-name array AddItem normalisation
|
||||
|
||||
`AddItem` for a bare array attribute name (e.g. `Tank01.Temperature`) is
|
||||
automatically normalised to `Tank01.Temperature[]` by the gateway so the
|
||||
worker can resolve the full array. You do not need to append `[]` in client
|
||||
code; the gateway handles it.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The Galaxy Repository service exposes a read-only browse over the AVEVA System
|
||||
|
||||
@@ -17,12 +17,12 @@ use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
|
||||
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
|
||||
MxCommandRequest, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand,
|
||||
RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, StreamEventsRequest,
|
||||
SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, UnAdviseItemBulkCommand,
|
||||
UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand,
|
||||
WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry,
|
||||
WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
||||
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
|
||||
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
|
||||
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
|
||||
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
|
||||
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
|
||||
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
||||
};
|
||||
use crate::value::MxValue;
|
||||
|
||||
@@ -547,6 +547,62 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a sparse, default-filled array: only the given elements
|
||||
/// (index → scalar value) are set; every unmentioned index up to
|
||||
/// `total_length` is written as the element type's default (a reset,
|
||||
/// **not** a preserve). The gateway expands the sparse representation into
|
||||
/// a whole-array write before forwarding to the worker.
|
||||
///
|
||||
/// This is a convenience wrapper around [`Session::write`] that builds the
|
||||
/// `MxSparseArray` wire value for you. Call [`Session::write`] directly
|
||||
/// if you need to pass a pre-built [`MxValue`] carrying a full
|
||||
/// `MxArray`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidArgument`] (propagated from the gateway) if
|
||||
/// `total_length` is zero, exceeds the gateway's maximum array length, or
|
||||
/// any element index is out of range. Returns [`Error::Command`] for
|
||||
/// non-OK worker statuses, plus the usual transport/status errors.
|
||||
pub async fn write_array_elements(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
item_handle: i32,
|
||||
element_data_type: MxDataType,
|
||||
total_length: u32,
|
||||
elements: impl IntoIterator<Item = (u32, MxValue)>,
|
||||
user_id: i32,
|
||||
) -> Result<(), Error> {
|
||||
use crate::generated::mxaccess_gateway::v1::mx_value::Kind;
|
||||
|
||||
let sparse_elements: Vec<MxSparseElement> = elements
|
||||
.into_iter()
|
||||
.map(|(index, value)| MxSparseElement {
|
||||
index,
|
||||
value: Some(value.into_proto()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sparse_value = ProtoMxValue {
|
||||
data_type: element_data_type as i32,
|
||||
variant_type: String::new(),
|
||||
kind: Some(Kind::SparseArrayValue(MxSparseArray {
|
||||
element_data_type: element_data_type as i32,
|
||||
total_length,
|
||||
elements: sparse_elements,
|
||||
})),
|
||||
..ProtoMxValue::default()
|
||||
};
|
||||
|
||||
self.write(
|
||||
server_handle,
|
||||
item_handle,
|
||||
MxValue::from_proto(sparse_value),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run MXAccess `Write2` (single-value with caller-supplied timestamp).
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -173,7 +173,11 @@ impl MxValueProjection {
|
||||
Some(Kind::TimestampValue(value)) => Self::Timestamp(*value),
|
||||
Some(Kind::ArrayValue(value)) => Self::Array(MxArrayValue::from_proto(value.clone())),
|
||||
Some(Kind::RawValue(value)) => Self::Raw(value.clone()),
|
||||
None => Self::Unset,
|
||||
// SparseArrayValue is write-only: the gateway expands it before forwarding
|
||||
// to the worker and never emits it in events or read replies. Map it to
|
||||
// Unset so any read-side code that encounters a stale or mis-routed
|
||||
// sparse value degrades gracefully rather than panicking.
|
||||
Some(Kind::SparseArrayValue(_)) | None => Self::Unset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
|
||||
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
|
||||
MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
|
||||
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
||||
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
|
||||
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
||||
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest,
|
||||
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
|
||||
WriteSecuredBulkEntry,
|
||||
@@ -1091,3 +1091,92 @@ fn case_by_id<'a>(cases: &'a [Value], id: &str) -> &'a Value {
|
||||
.find(|case| case["id"].as_str() == Some(id))
|
||||
.unwrap_or_else(|| panic!("missing fixture case {id}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// write_array_elements — proto shape unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the proto `MxValue` that `write_array_elements` would send and assert
|
||||
/// the sparse oneof variant has the correct `total_length` and elements.
|
||||
fn sparse_int32_value(
|
||||
total_length: u32,
|
||||
elements: impl IntoIterator<Item = (u32, i32)>,
|
||||
) -> MxValue {
|
||||
let sparse_elements: Vec<MxSparseElement> = elements
|
||||
.into_iter()
|
||||
.map(|(index, v)| MxSparseElement {
|
||||
index,
|
||||
value: Some(MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
variant_type: "VT_I4".to_owned(),
|
||||
kind: Some(Kind::Int32Value(v)),
|
||||
..MxValue::default()
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
variant_type: String::new(),
|
||||
kind: Some(Kind::SparseArrayValue(MxSparseArray {
|
||||
element_data_type: MxDataType::Integer as i32,
|
||||
total_length,
|
||||
elements: sparse_elements,
|
||||
})),
|
||||
..MxValue::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_array_elements_proto_shape_has_sparse_oneof_kind() {
|
||||
let proto = sparse_int32_value(5, [(0, 10), (3, 30)]);
|
||||
|
||||
let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else {
|
||||
panic!("expected SparseArrayValue kind, got {:?}", proto.kind);
|
||||
};
|
||||
|
||||
assert_eq!(sparse.total_length, 5, "total_length must round-trip");
|
||||
assert_eq!(sparse.elements.len(), 2, "two elements supplied");
|
||||
assert_eq!(sparse.element_data_type, MxDataType::Integer as i32);
|
||||
|
||||
let elem0 = &sparse.elements[0];
|
||||
assert_eq!(elem0.index, 0);
|
||||
assert_eq!(
|
||||
elem0.value.as_ref().unwrap().kind,
|
||||
Some(Kind::Int32Value(10))
|
||||
);
|
||||
|
||||
let elem3 = &sparse.elements[1];
|
||||
assert_eq!(elem3.index, 3);
|
||||
assert_eq!(
|
||||
elem3.value.as_ref().unwrap().kind,
|
||||
Some(Kind::Int32Value(30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_array_elements_empty_elements_is_valid_all_defaults() {
|
||||
let proto = sparse_int32_value(8, []);
|
||||
let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else {
|
||||
panic!("expected SparseArrayValue kind");
|
||||
};
|
||||
assert_eq!(sparse.total_length, 8);
|
||||
assert!(
|
||||
sparse.elements.is_empty(),
|
||||
"no elements means every index defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_array_value_round_trips_through_client_mx_value_projection_as_unset() {
|
||||
// SparseArrayValue is write-only. If it ever arrives on the read path
|
||||
// (e.g. a future version bug), the projection should degrade to Unset
|
||||
// rather than panic, because the enum variant is not readable.
|
||||
let proto = sparse_int32_value(4, [(1, 99)]);
|
||||
let client_value = ClientMxValue::from_proto(proto);
|
||||
assert_eq!(
|
||||
client_value.projection(),
|
||||
&MxValueProjection::Unset,
|
||||
"write-only SparseArrayValue must project to Unset, not panic"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,20 @@ private static MxValue CreateNullValue(
|
||||
}
|
||||
```
|
||||
|
||||
### Sparse array expansion (write path, gateway only)
|
||||
|
||||
`MxSparseArray` — the `sparse_array_value` arm on `MxValue` — is a write-only
|
||||
shorthand. The worker never produces or receives it; the gateway expands it into
|
||||
a full `MxArray` before the command reaches the named pipe. Expansion allocates
|
||||
a complete array of `total_length` slots, initializes every slot to the element
|
||||
type's default (bool → `false`; numeric → `0`; string → `""`; time/timestamp →
|
||||
Unix epoch), then writes each `MxSparseElement` at its declared index. The
|
||||
resulting `array_value` is an ordinary `MxArray` that passes through the
|
||||
conversion layer unchanged. The worker therefore still performs a single
|
||||
whole-array COM write, preserving MXAccess parity. Unmentioned indices are
|
||||
**reset** to their type default, not preserved from prior state — there is no
|
||||
read-modify-write merge.
|
||||
|
||||
### Array projection
|
||||
|
||||
`ConvertArray` records the rank and per-dimension lengths so multi-dimensional `SAFEARRAY` shapes survive the round trip. The element type is resolved from the caller-supplied hint or the CLR element type via `ResolveArrayElementDataType`, then dispatched to the matching typed builder (`ConvertBoolArray`, `ConvertInt64Array`, `ConvertTimestampArray`, and so on).
|
||||
|
||||
+75
-1
@@ -481,6 +481,68 @@ metadata rather than dropped. If a value cannot be losslessly converted, the
|
||||
worker should return both the best typed projection and enough diagnostic
|
||||
metadata to reproduce the case.
|
||||
|
||||
### MxSparseArray — default-fill partial array writes (write-only)
|
||||
|
||||
`MxSparseArray` is a write-only `oneof kind` arm on `MxValue` that lets clients
|
||||
send only the indices they want to change plus a total length, rather than
|
||||
marshalling the entire array every write. The worker never produces or receives
|
||||
it; expansion happens entirely in the gateway before the command reaches the pipe.
|
||||
|
||||
```protobuf
|
||||
message MxSparseArray {
|
||||
MxDataType element_data_type = 1;
|
||||
uint32 total_length = 2;
|
||||
repeated MxSparseElement elements = 3;
|
||||
}
|
||||
|
||||
message MxSparseElement {
|
||||
uint32 index = 1;
|
||||
MxValue value = 2; // scalar
|
||||
}
|
||||
```
|
||||
|
||||
**Expansion.** Before forwarding any write command to the worker the gateway
|
||||
allocates a full array of `total_length` slots, initializes every slot to the
|
||||
element type's default, places each `MxSparseElement` at its index, then
|
||||
replaces the `MxValue` with a normal `array_value` (`MxArray`). The worker
|
||||
receives an ordinary whole-array write — parity is preserved.
|
||||
|
||||
Default values by element type:
|
||||
|
||||
| Element type | Default |
|
||||
|---|---|
|
||||
| `Boolean` | `false` |
|
||||
| `Integer` / `LongInteger` | `0` |
|
||||
| `Float` / `Double` | `0` |
|
||||
| `String` | `""` |
|
||||
| `Time` / `Timestamp` | Unix epoch (1970-01-01T00:00:00Z) |
|
||||
|
||||
Unmentioned indices take the element type's default — this is a **reset**, not a
|
||||
preserve. There is no read-modify-write merge: adding that would introduce cache
|
||||
staleness, a race window against other writers, and the latency of a round-trip
|
||||
read, all of which contradict MXAccess semantics.
|
||||
|
||||
**Validation.** The gateway rejects the following with `InvalidArgument`:
|
||||
|
||||
- `total_length == 0`
|
||||
- any `index >= total_length`
|
||||
- duplicate indices
|
||||
- `element_data_type` that is `Raw` or `Unspecified`
|
||||
- an element `value` whose kind does not match `element_data_type`
|
||||
- `total_length` exceeds the gateway-configured maximum array length
|
||||
|
||||
An empty `elements` list with a non-zero `total_length` is valid — it writes an
|
||||
all-defaults array of length `total_length` (explicit reset). A `sparse_array_value`
|
||||
arriving on any read or event path is rejected as a guard; the worker never
|
||||
produces one.
|
||||
|
||||
**Non-goals.** There is no preserve-unchanged read-modify-write merge, no
|
||||
element-wise COM write (MXAccess has no such API), and no change to `ReadBulk`
|
||||
string addressing.
|
||||
|
||||
`sparse_array_value` is accepted by every write variant: `Write`, `Write2`,
|
||||
`WriteSecured`, `WriteSecured2`, and each `*BulkEntry` entry.
|
||||
|
||||
## Status Model
|
||||
|
||||
Represent `MXSTATUS_PROXY` explicitly:
|
||||
@@ -1057,7 +1119,19 @@ Known important parity areas from existing captures:
|
||||
- Writing an array attribute replaces the whole array — it is not an
|
||||
element-wise patch. To change a subset of elements the caller must send the
|
||||
full array (unchanged elements included); sending only the changed elements
|
||||
resizes the attribute.
|
||||
resizes the attribute. `MxSparseArray` provides a default-fill shorthand for
|
||||
this: the gateway reconstructs the full array from the supplied sparse
|
||||
representation (unmentioned indices → type default) before sending the
|
||||
whole-array write to the worker.
|
||||
- Array attribute addresses require the `[]` body suffix to be write-capable.
|
||||
The gateway normalizes bare-name addresses at `AddItem` time: when Galaxy
|
||||
metadata confirms `is_array`, the gateway appends `[]` before registering the
|
||||
handle with the worker. When metadata is unavailable or the address is not
|
||||
recognized as an array, the address is forwarded unchanged so existing
|
||||
behavior is not regressed. The normalized address is stored in
|
||||
`SessionItemRegistration.TagAddress` and applies consistently to all
|
||||
subsequent writes on that handle. `ReadBulk` is unaffected — it uses raw
|
||||
address strings with its own ephemeral registration.
|
||||
|
||||
The gateway should not "fix" these behaviors unless the client explicitly opts
|
||||
into a non-parity mode.
|
||||
|
||||
Reference in New Issue
Block a user