Compare commits

...

5 Commits

Author SHA1 Message Date
Joseph Doherty 3a8f2bed4e feat(client-rust): add write_array_elements default-fill helper and document semantics
Handles the new MxSparseArray wire type (proto field 19 on MxValue::Kind):
- value.rs: map SparseArrayValue to MxValueProjection::Unset (write-only; never emitted on read path)
- session.rs: add write_array_elements() that builds the sparse proto value and delegates to write()
- tests: three unit tests asserting proto shape, empty-elements case, and read-path Unset projection
- README: document write_array_elements default-fill semantics and bare-name [] normalisation
2026-06-18 03:02:15 -04:00
Joseph Doherty b7f29f3048 feat(client-go): add WriteArrayElements default-fill helper and document semantics
Regenerate Go proto types from mxaccess_gateway.proto so MxSparseArray,
MxSparseElement, and MxValue_SparseArrayValue appear in the generated
package; add MxSparseArray/MxSparseElement type aliases to types.go;
add Session.WriteArrayElements and the unexported buildSparseArrayValue
builder; add three unit tests covering the sparse oneof structure,
empty-map case, and the round-trip through WriteArrayElements; update
README with default-fill reset semantics and auto-normalize note.
2026-06-18 03:01:55 -04:00
Joseph Doherty 0702551c25 feat(client-python): add write_array_elements default-fill helper and document semantics
Regenerate Python proto bindings to pick up MxSparseArray/MxSparseElement/
sparse_array_value from the shared mxaccess_gateway.proto. Add
Session.write_array_elements which builds an MxValue(sparse_array_value=…)
from a {index→scalar} dict and delegates to the existing write(). Add 8 pytest
tests covering builder correctness and full round-trip wire shape. Update
README with a default-fill semantics paragraph and bare-name array-write note.
2026-06-18 03:01:45 -04:00
Joseph Doherty db9c68ca9c docs: document MxSparseArray default-fill writes and bare-name array AddItem 2026-06-18 03:00:19 -04:00
Joseph Doherty 95b5b09a67 feat(client-dotnet): add WriteArrayElementsAsync default-fill helper and document semantics
Adds a public WriteArrayElementsAsync helper on MxGatewaySession that builds
an MxValue{SparseArrayValue} and delegates to the existing WriteAsync. Extracts
the MxValue construction into an internal static BuildSparseArray builder for
unit-testability. Two new tests cover builder output shape and the full write
command path. README documents the reset (not preserve) semantics alongside
the existing whole-array guidance.
2026-06-18 02:59:43 -04:00
23 changed files with 2333 additions and 699 deletions
+24
View File
@@ -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>
+12
View File
@@ -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())
}
}
+52
View File
@@ -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) {
+7
View File
@@ -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.
+27
View File
@@ -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"
+33
View File
@@ -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
+62 -6
View File
@@ -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
+5 -1
View File
@@ -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,
}
}
}
+91 -2
View File
@@ -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"
);
}
+14
View File
@@ -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
View File
@@ -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.