Skip to content

v4.6.0

Latest

Choose a tag to compare

@GianfriAur GianfriAur released this 17 Sep 13:11
· 2 commits to master since this release
93fc505

[v4.6.0] - 2026-09-17 Self-Healing

Added

  • Sessions survive reconnects and process restarts. reconnect() — and so
    auto-retry — opens a new secure channel and reactivates the existing session
    on it with ActivateSession, instead of creating a new session, so
    subscriptions and monitored items keep running; SessionReactivated is
    dispatched. If the server rejects the session, a new one is created. The
    session knowledge is kept across failed reconnect attempts while the server
    is unreachable. ClientBuilder::setReactivateSession(false) restores the
    previous behaviour; an expired session (setRecreateExpiredSession()) always
    gets a new one. Client::getSessionState() returns a SessionState
    (endpoint, authentication token, server nonce, session timeout; JSON
    serializable, restored with SessionState::fromArray()), Client::suspend()
    closes the channel and socket without closing the session, and
    ClientBuilder::resumeSession() reactivates a saved session on connect.
    SessionService::decodeActivateSessionResponse() now returns the server
    nonce, which signs the next activation. Against UA-.NETStandard, an anonymous
    session resumed by a new client 15 s after a simulated crash delivers every
    sampled value, with and without SignAndEncrypt; a subscription cannot be
    transferred to a new anonymous session (BadUserAccessDenied), so
    reactivation is the only way to keep it.

  • Expired sessions are recreated. When a call fails with
    BadSessionIdInvalid, BadSessionClosed or BadSessionNotActivated,
    executeWithRetry() reconnects with a new session and repeats the call once,
    independently of setAutoRetry(). The auto-retry documentation already
    listed session invalidation as recoverable, but only ConnectionException
    was handled. On by default; ClientBuilder::setRecreateExpiredSession(false)
    turns it off. Against UA-.NETStandard, a 10 s session idle for 30 s reads
    again on a new session.

  • Security token renewal can be turned off with
    ClientBuilder::setRenewSecurityToken(false); it stays on by default.
    ClientBuilderInterface gains both setters and their is…() getters.

  • Configurable session timeout. ClientBuilder::setSessionTimeout(float $milliseconds) (default 120000, the value previously hard-coded in both
    CreateSession encoders) and getSessionTimeout(); Client::getSessionTimeout()
    and OpcUaClientInterface::getSessionTimeout() return the timeout the server
    granted, which the CreateSession decoder previously discarded.
    SessionService::encodeCreateSessionRequest() takes the requested timeout and
    decodeCreateSessionResponse() returns revisedSessionTimeout. Against
    UA-.NETStandard, 1000 is revised to 10000 and a 10000 session idle for
    30 s fails the next call with BadSessionIdInvalid (the server checks for
    expired sessions every 10 s). ClientBuilderInterface
    gains setSessionTimeout() / getSessionTimeout() and OpcUaClientInterface
    gains getSessionTimeout(): custom implementations must add them.

  • MonitoredItemResult::$selectClauseResults, an optional trailing
    constructor parameter carried through the wire format. The CreateMonitoredItems
    decoder discarded the EventFilterResult (ns=0;i=736,
    ServiceTypeId::EVENT_FILTER_RESULT_ENCODING), so an event item rejected for
    a bad select clause gave no hint of which clause failed. Against open62541,
    ['EventId', 'DoesNotExist', 'Severity'] now reports [Good, BadNodeIdUnknown, Good]; UA-.NETStandard sends no per-clause results.

  • PublishResult::$publishTime and PublishResult::$acknowledgementResults,
    added as optional trailing constructor parameters and carried through the
    wire format. The Publish decoder read both and discarded them: an
    acknowledgement the server rejected went unnoticed, and publish() lacked
    the publish time republish() already returned. Against UA-.NETStandard, a
    valid acknowledgement reports Good, an unknown sequence number
    BadSequenceNumberUnknown and an unknown subscription
    BadSubscriptionIdInvalid.

  • SecureChannelRenewed event (channelId, tokenId, revisedLifetime),
    dispatched after each security token renewal.

  • Data change filters. Items passed to createMonitoredItems() and
    modifyMonitoredItems() accept a filter key —
    ['trigger' => int, 'deadbandType' => int, 'deadbandValue' => float] —
    encoded as a DataChangeFilter ExtensionObject
    (ServiceTypeId::DATA_CHANGE_FILTER_ENCODING, ns=0;i=724). Omitted keys
    default to the StatusValue trigger with no deadband. A Percent deadband on a
    variable without an EURange is rejected per item with
    BadMonitoredItemFilterUnsupported.

  • discardOldest on creation. createMonitoredItems() accepts
    discardOldest (default true, as before); it could previously be set only
    through modifyMonitoredItems().

  • MonitoredItemsBuilder: monitoringMode(), discardOldest() and
    dataChangeFilter(), so the builder covers every item key.

  • republished on notification events. DataChangeReceived,
    EventNotificationReceived and the nine alarm events carry
    bool $republished (default false, added as the last constructor
    parameter).

Changed

  • Browse raises the result's Bad status. browse(),
    browseWithContinuation(), browseNext() and, through them, browseAll()
    and browseRecursive() discarded the StatusCode of the BrowseResult, so a
    node that does not exist returned an empty array indistinguishable from a
    node without children. A Bad result now raises ServiceException with that
    status code (BadNodeIdUnknown, BadReferenceTypeIdInvalid,
    BadContinuationPointInvalid, …); a rejected browse is not cached. Callers
    that relied on an empty array for a missing node must catch it.
  • HistoryRead raises the result's Bad status. historyReadRaw(),
    historyReadProcessed() and historyReadAtTime() discarded the
    StatusCode of the node's HistoryRead result, so a rejected read — an
    unknown aggregate, an unsupported operation — returned an empty array
    indistinguishable from "no data". A Bad result now raises
    ServiceException with that status code. Callers that relied on an empty
    array for a rejected read must catch it.
  • republish() dispatches PSR-14 events, consistent with publish():
    DataChangeReceived, EventNotificationReceived and the alarm events, with
    republished: true. A retransmission can repeat a notification publish()
    already delivered, so listeners that must not process a value twice check
    the flag. PublishResponseReceived and SubscriptionKeepAlive remain
    publish()-only.

Fixed

  • Client::getAutoRetry() reported a retry that never happened. For a
    connected client whose autoRetry was never set it returned 1, while
    executeWithRetry() applies autoRetry ?? 0 and does not retry. It now
    returns 0, the effective default; setAutoRetry() still enables retries.
    Verified against UA-.NETStandard: without setAutoRetry() a closed socket
    raises ConnectionException, with setAutoRetry(1) the next read reconnects.

  • History reads stopped at the server's first page. The documentation said
    continuation points were followed transparently, but the decoder discarded
    them: against open62541, which returns at most 1 024 values per response,
    historyReadRaw() of 1 500 values returned 1 024 without an error, whatever
    numValuesPerNode was. historyReadRaw(), historyReadProcessed() and
    historyReadAtTime() now follow continuation points until the server is
    done; with numValuesPerNode set, reading stops at that many values and the
    pending continuation point is released. The HistoryRead encoders accept a
    continuation point and the release flag, and
    HistoryReadService::decodeHistoryReadResponseWithContinuation() returns
    the values with the continuation point.

  • The secure channel security token was never renewed. The client asked
    for a one-hour token, discarded the lifetime the server granted and had no
    renewal path, so every connection failed once the token expired: against
    UA-.NETStandard with a 30 s token, the first request after expiry returned
    0x807F0000 and the server then closed the channel, losing session and
    subscriptions. The client now schedules a renewal at 75% of the revised
    lifetime and, on the first request after that point, sends an
    OpenSecureChannel Renew on the open channel — with a fresh nonce and new
    symmetric keys when security is active — then keeps using the same session.
    A connection idle past the token lifetime renews on its next request.
    SecureChannelRequest::encode() takes the request type and sequence number,
    SecureChannel::createOpenSecureChannelMessage() a $renew flag, and
    SessionService::setTokenId() updates the token of a channel without
    security.

  • Responses larger than one message chunk were truncated. The transport
    returned a single chunk and unwrapResponse() treated it as the whole
    message, while the Hello advertises no message size or chunk count limit, so
    servers legitimately split any response above the negotiated buffer
    (65 535 bytes) into chunks. The client decoded the first chunk only — a
    one-hour historyReadRaw() failed with Buffer underflow — and left the
    remaining chunks on the socket. unwrapResponse() now reads the chunks that
    follow an intermediate (C) chunk and appends their bodies, in plain and
    secured channels alike (each chunk is verified and decrypted on its own). An
    abort chunk (A) raises ServiceException with the server's error and
    reason; a chunk belonging to another request raises ProtocolException.
    Verified against UA-.NETStandard: a 262 144-byte readFile() in one call,
    with no security, Sign and SignAndEncrypt.

  • Responses were not matched to their request. A response left on the
    socket — the tail of a truncated message, or the answer to a request whose
    reply was never read — was taken as the reply to the next call, so the
    connection kept returning other requests' data without an error. The client
    now remembers the request ID issued last and discards responses to any other
    request. Transports that own the secure channel (such as the HTTPS
    transport, which pairs each response with its request) are not checked.

  • The data change filter was silently ignored. The recipe
    Subscribing to data changes documented a deadband filter, but both
    encoders always wrote a null filter, so every change was reported. Verified
    against UA-.NETStandard: with an Absolute deadband of 5 on a value of 100, a
    write of 102 is now suppressed and a write of 110 reported.

  • Six StatusCode constants had the wrong value. Hand-written, they
    held the value of a different code: BadNotReadable 0x803E0000 →
    0x803A0000 (the old value is BadNotFound), BadNoData 0x80B10000 →
    0x809B0000 (old value BadNoDataAvailable), BadAggregateNotSupported
    0x80D80000 → 0x80D50000 (old value BadBoundNotSupported) and
    UncertainDataSubNormal 0x40A30000 → 0x40A40000 (the old value is not a
    standard code). Comparisons against them never matched what servers send, and
    the client-side aggregate calculators emitted the wrong codes.
    BadFileHandleInvalid and BadFileNotOpened are not standard codes: they
    are deprecated and now alias the codes FileType methods return,
    BadInvalidArgument (0x80AB0000, was 0x80E70000, BadDataSetIdInvalid)
    and BadInvalidState (0x80AF0000, was 0x80E80000,
    BadTransactionPending). Every value is checked by an integration test
    against the code UA-.NETStandard itself returns.

  • StatusCode::getName() returned hex for most codes. It knew only the
    30 codes with a constant, so a server's BadMonitoredItemFilterUnsupported
    came back as 0x80440000, and any code with InfoBits — a Good value with
    Overflow, say — came back as hex too. It now names all 272 standard codes
    (generated from the OPC Foundation's UA-Nodeset StatusCode.csv) from the
    upper 16 bits and appends the set low bits in brackets: StructureChanged,
    SemanticsChanged and, under the DataValue InfoType, the limit, overflow and
    historian flags (Good [LimitHigh, Overflow]); undefined bits are appended
    as hex. The table references the constants by name.

  • Event reference. Key fields listed properties the classes do not have
    (acknowledger, shelved, oldSeverity / newSeverity); the monitored
    item, SubscriptionTransferred and TriggeringConfigured events were
    described as firing on Good only, while they fire for every result with its
    statusCode; the alarm rows now describe the actual deduction rules. The
    @see tags of the subscription events pointed to the removed
    ManagesSubscriptionsTrait; they now point to SubscriptionModule.

  • republish() now returns the retransmitted notifications.
    SubscriptionService::decodeRepublishResponse() read the header of every
    NotificationData entry and skipped its body, so notifications was always
    an empty array even when the server retransmitted data. Publish and
    Republish responses now share one decoder (DecodesNotificationDataTrait),
    so republish() returns the same DataChangeNotification /
    EventNotification objects as publish(). Entries without a body, with
    an empty body or XML-encoded are skipped without desynchronising the
    decoder, in both responses.

Tests

  • Session reactivation (SessionReactivationTest, against UA-.NETStandard,
    None and Basic256Sha256 SignAndEncrypt): a dropped connection reactivates
    the same session and the subscription's counter values continue without a
    gap; with reactivation off a new session is created; a client resuming the
    session of a crashed one after 15 s receives every missed value (republishing
    the unacknowledged sequence numbers); a suspended session resumes; an unknown
    saved session falls back to a new one. The reactivation cases fail when the
    pending session is ignored. Unit: SessionState JSON round trip and
    validation, builder and MockClient options.

  • Auto-retry default (AutoRetryDefaultTest, against UA-.NETStandard): a
    client without setAutoRetry() reports 0 and throws ConnectionException
    after its socket is closed; with setAutoRetry(1) the next read reconnects.
    Unit: no RetryAttempt by default, one with autoRetry 1. The existing
    tests that expected getAutoRetry() to return 1 after connecting now
    expect 0.

  • Session recreation and token renewal flags (against UA-.NETStandard): an
    expired 10 s session reads again on a new session with ClientReconnecting,
    and with recreation off the call fails with BadSessionIdInvalid; with
    renewal off, reads on the 30 s token server fail once the token expires. The
    recreation test fails without the change. Unit: builder and MockClient
    defaults and setters, no OPN sent when renewal is off.

  • Session timeout (SessionTimeoutTest, against UA-.NETStandard): the default
    is granted at 120 000 ms, 15 000 ms at 15 000 and 1 000 ms raised to
    10 000; after 30 s idle the 10 s session fails with BadSessionIdInvalid
    while the default one still reads. Unit: builder default and setter, the
    requested value in the CreateSession request, MockClient.

  • Event filter results (EventFilterResultTest, against open62541): valid
    clauses give a Good item without per-clause results; a mixed and a fully
    invalid selection report the status of every clause. Unit: the
    EventFilterResult is decoded and survives the wire round-trip.

  • Publish (PublishResultTest, against UA-.NETStandard): publishTime is
    within seconds of now, and three acknowledgements report Good,
    BadSequenceNumberUnknown and BadSubscriptionIdInvalid. Unit: both fields
    are decoded and survive the wire round-trip.

  • History pagination (HistoryContinuationTest, against open62541-historizing):
    the server's first page holds 1 024 of 1 500 inserted values plus a
    continuation point; historyReadRaw() returns all 1 500 with a second
    HistoryRead that carries it, and numValuesPerNode 1 100 and 5 return exactly
    those values (the latter releasing the continuation point), each in two
    requests counted on the wire. Unit: the
    continuation point and release flag are encoded and decoded, pages are
    followed for raw, processed and at-time reads, and the continuation point is
    released when numValuesPerNode is reached.

  • Token renewal (SecureChannelRenewalTest, against the test suite's
    opcua-short-token-lifetime server with a 30 s token): in None, Sign and
    SignAndEncrypt, 45 s of reads succeed with at least one renewal and the same
    session; a subscription created before the renewal keeps delivering data
    changes after it, on the same session; after 40 s idle the next read renews
    and succeeds. All fail without the renewal. Unit: the Renew request encoding, renewal once due with
    the new token on the next request, no renewal before.

  • Browse (BrowseStatusTest, against UA-.NETStandard): a missing node raises
    BadNodeIdUnknown from browse(), browseAll() and browseRecursive(),
    also on a repeated call (not cached); an unknown reference type raises
    BadReferenceTypeIdInvalid; an unknown continuation point raises
    BadContinuationPointInvalid; a node without children returns []. Unit:
    a Bad BrowseResult raises its status, an Uncertain one keeps decoding.

  • Unit (ClientChunkedResponseTest): chunked responses are assembled; stale
    responses, single or chunked, are discarded; abort chunks, ERR messages
    between chunks and chunks of another request are raised; transports with an
    external secure channel skip the request check. The unit mock transports echo
    the request ID of the last request, as a server does. Integration, against
    UA-.NETStandard with no security, Sign and SignAndEncrypt
    (ChunkedMessageTest): a 262 144-byte readFile() in one call, and a read
    after an abandoned request returns its own value.

  • Unit: a Republish response carrying DataChange, StatusChange and
    EventNotificationList entries decodes into typed objects; bodyless, empty
    and XML-encoded entries are skipped. Integration, against UA-.NETStandard:
    for every unacknowledged sequence number, republish() returns the same
    client handles and values publish() delivered.

  • Unit: the DataChangeFilter ExtensionObject is encoded for create and modify,
    with defaults for missing keys; discardOldest is encoded on creation; the
    builder sets mode, discard policy and filter; republish() dispatches
    data change, event and alarm events flagged republished and no
    publish-only events, while publish() events stay unflagged. Integration,
    against UA-.NETStandard: an Absolute deadband suppresses small changes and is
    removed through modifyMonitoredItems(); a Percent deadband on a variable
    without an EURange is rejected. The new tests fail against the previous
    encoder and module.

  • Integration, against UA-.NETStandard (StatusCodeConstantsTest): each
    corrected constant equals the code the stack returns — BadNotReadable on a
    write-only variable, BadNoData and UncertainDataSubNormal from the SDK's
    aggregate calculators, BadAggregateNotSupported for an unknown aggregate,
    and BadFileHandleInvalid / BadFileNotOpened from FileType Read.
    Requires the test suite's new processed-history support and
    HistoricalWithBadSamples node. Unit: getName() names every standard
    code and appends InfoBits.