Repository navigation
Server Methods
Milo 1.2.0-RC1, Java 17. API baseline 69cb25087804.
Data access guarded client writes to Setpoint. The thermostat's other way to change Setpoint is AdjustSetpoint, a Method that adds a Delta and returns the new setpoint as a typed result. This page walks through how the tutorial builds that Method and its handler, then covers complete results, explaining failures to the client, shared Methods, and handler lifetime. Calling methods shows the client side.
A Method is a node, and its behavior lives in a MethodInvocationHandler installed on that node. A CallMethodRequest names both an ObjectId and a MethodId, so the server can check that the Method belongs to that Object, and a discoverable Method cannot be called on an unrelated Object.
When a Call arrives, the service first authorizes it for the Session. The managed address space then finds the Object, finds the Method among the Object's components, and selects the handler for that ObjectId. If the handler extends AbstractMethodInvocationHandler, it checks input count, rank, dimensions, and data type against the declared arguments, then calls your value validation, and only then calls your operation. Each step can end the call with its own status, and the client receives one CallMethodResult per Method call, with a status, per-input results, and outputs.
The tutorial creates the Method node in TutorialNamespace after Thermostat and Setpoint exist, binds a handler that knows the Setpoint node, and makes the Method a component of Thermostat.
UaMethodNode adjustSetpoint =
UaMethodNode.builder(getNodeContext())
.setNodeId(newNodeId("AdjustSetpoint"))
.setBrowseName(newQualifiedName("AdjustSetpoint"))
.setDisplayName(LocalizedText.english("AdjustSetpoint"))
.setDescription(LocalizedText.english("Add Delta to Setpoint and return the result."))
.build();
getNodeManager().addNode(adjustSetpoint);
var handler = new AdjustSetpointHandler(adjustSetpoint, setpoint);
adjustSetpoint.bindInvocationHandler(handler);
thermostat.addComponent(adjustSetpoint);thermostat.addComponent(...) adds the HasComponent reference that makes AdjustSetpoint callable with Thermostat as the ObjectId. bindInvocationHandler(handler) installs the handler with setInvocationHandler(...) and publishes the argument definitions it declares through setInputArguments(...) and setOutputArguments(...), skipping an empty declaration. Clients read the resulting InputArguments and OutputArguments properties to learn the signature, but the handler checks each call against its own declarations, so binding both from one handler keeps them identical. The properties only describe the operation, and the handler implements it. bindInvocationHandler takes an AbstractMethodInvocationHandler, so for a raw MethodInvocationHandler you make those three calls yourself.
The handler declares one Double input, Delta, and one Double output, the new Setpoint. It rejects a non-finite Delta during validation and refuses any adjustment that would leave the 0 to 100 range.
/** Adds Delta to Setpoint when the result stays within 0.0 to 100.0. */
private static final class AdjustSetpointHandler extends AbstractMethodInvocationHandler {
private static final Argument DELTA =
new Argument(
"Delta",
NodeIds.Double,
ValueRanks.Scalar,
null,
LocalizedText.english("The amount to add to the setpoint."));
private static final Argument NEW_SETPOINT =
new Argument(
"Setpoint",
NodeIds.Double,
ValueRanks.Scalar,
null,
LocalizedText.english("The setpoint after the adjustment."));
private final UaVariableNode setpoint;
AdjustSetpointHandler(UaMethodNode node, UaVariableNode setpoint) {
super(node);
this.setpoint = setpoint;
}
@Override
public Argument[] getInputArguments() {
return new Argument[] {DELTA};
}
@Override
public Argument[] getOutputArguments() {
return new Argument[] {NEW_SETPOINT};
}
@Override
protected void validateInputArgumentValues(Variant[] inputs) throws InvalidArgumentException {
// The SDK's type check lets a null value through, so test for a Double here.
boolean finite = inputs[0].value() instanceof Double delta && Double.isFinite(delta);
if (!finite) {
Diagnostic reason = Diagnostic.text("Delta must be a finite number");
throw InvalidArgumentException.builder(inputs.length)
.reject(0, StatusCodes.Bad_OutOfRange, reason)
.build();
}
}
@Override
protected Variant[] invoke(InvocationContext context, Variant[] inputs) throws UaException {
double delta = (Double) inputs[0].value();
// Writes store Setpoint while holding the node's lock, so holding it here keeps a
// concurrent write or call from changing Setpoint between the read and the update.
synchronized (setpoint) {
double current = (Double) setpoint.getValue().value().value();
double adjusted = current + delta;
boolean inRange = adjusted >= 0.0 && adjusted <= 100.0;
if (!inRange) {
Diagnostic reason = Diagnostic.text("Setpoint must stay between 0.0 and 100.0");
throw new UaException(StatusCodes.Bad_OutOfRange, "setpoint " + adjusted, reason);
}
setpoint.setValue(new DataValue(new Variant(adjusted)));
return new Variant[] {new Variant(adjusted)};
}
}
}The two callbacks split the work by kind of failure. validateInputArgumentValues runs after the SDK's argument checks and judges each input on its own. Use InvalidArgumentException for value constraints. Its builder takes the number of inputs, and each reject names an input by position, gives the status for it, and can attach a Diagnostic that says why. Inputs you do not reject are Good. The exception makes the operation status Bad_InvalidArgument, so the client can tell which input failed. The instanceof test also rejects a null Delta, which passes the SDK's type check because a null value matches any declared type. invoke then decides whether the operation itself can run. It reads, checks, and stores Setpoint while holding the Setpoint node's lock, the same lock a client write holds when it stores a value, so a concurrent write or call cannot change Setpoint between the read and the update. Throwing UaException there makes its status the operation status, with no outputs, and returning output Variants implies Good. The range test is written as adjusted >= 0.0 && adjusted <= 100.0 so that it also fails for NaN, which Setpoint can hold if a client wrote it before the Data access filter was in place.
Both failures carry a Diagnostic, a short text for the client that explains the status. The exception's message, "setpoint " + adjusted, is for the server log and never reaches the client. Explain failures below covers when the client receives the text.
InvocationContext, the inherited nested type AbstractMethodInvocationHandler.InvocationContext, gives the handler the Session, the ObjectId, and the Method node. The imports are in the complete file on Your first server.
Calling AdjustSetpoint from a client with ObjectId Thermostat and MethodId AdjustSetpoint gives these results.
- With Setpoint at
22.0andDelta1.5, the call returns Good and outputs23.5, and clients subscribed to Setpoint receive23.5. - With
DeltaNaN or infinity, the call returnsBad_InvalidArgument, and the input argument result forDeltaisBad_OutOfRange. A client that asks for diagnostics also receives "Delta must be a finite number" forDelta. - With a
Deltathat would move Setpoint below0.0or above100.0, the call returnsBad_OutOfRangeand Setpoint is unchanged. A client that asks for diagnostics also receives "Setpoint must stay between 0.0 and 100.0". - With a String
Delta, the SDK's argument check returnsBad_InvalidArgumentwithBad_TypeMismatchas the input argument result, and the handler never runs.
The handler updates Setpoint with setValue(...), which skips the write filter from Data access, so the range check in invoke is the only one that applies to this call.
Override invokeResult(...) instead of invoke(...) when the outcome is not simply Good with outputs. It returns a MethodResult, built with good(...), uncertain(...), or bad(...). Each factory checks that its status has the matching severity, and bad(...) takes no outputs, because a Bad result must not carry them. withDiagnostic(...) attaches a Diagnostic that explains the status.
A variant of AdjustSetpoint could clamp the new setpoint to the 0 to 100 range instead of refusing, and say so with Good_Clamped.
@Override
protected MethodResult invokeResult(InvocationContext context, Variant[] inputs) {
double delta = (Double) inputs[0].value();
synchronized (setpoint) {
double current = (Double) setpoint.getValue().value().value();
double requested = current + delta;
double adjusted = Math.max(0.0, Math.min(100.0, requested));
setpoint.setValue(new DataValue(new Variant(adjusted)));
Variant newSetpoint = new Variant(adjusted);
if (adjusted == requested) {
return MethodResult.good(newSetpoint);
}
StatusCode clamped = new StatusCode(StatusCodes.Good_Clamped);
Diagnostic reason = Diagnostic.text("Setpoint clamped to " + adjusted);
return MethodResult.good(clamped, newSetpoint).withDiagnostic(reason);
}
}To reject individual inputs from invokeResult, throw InvalidArgumentException as validateInputArgumentValues does. A null result, or an InvalidArgumentException whose result count differs from the number of supplied inputs, is logged and reported as Bad_InternalError. A raw MethodInvocationHandler builds its CallMethodResult itself and must enforce the result rules on its own. The service replaces null results and statuses, but it does not apply every rule.
Override getRequiredInputArgumentCount(...) to let callers omit trailing inputs. Omitted arguments are absent from the input array your handler receives, while an explicit null keeps its position.
A status code says what went wrong, and a Diagnostic can say why: a symbolic id from your own namespace, a text, additional information, and the status of an underlying system. Attach one wherever the handler fails. The diagnostic of a UaException thrown from invoke or invokeResult, or attached to a MethodResult, explains the operation. The diagnostics given to InvalidArgumentException.Builder.reject explain the inputs.
The client receives them only when it asks. Its request's ReturnDiagnostics mask selects the fields it wants, and the Call service sends only those, along with the strings they refer to. A client that sends no mask, which is the default in Milo's client, receives the statuses alone. The server's DiagnosticsPolicy then decides what each client may see. The default sends nothing for security-related statuses such as Bad_UserAccessDenied, as OPC UA requires, and you can configure a stricter one with OpcUaServerConfigBuilder.setDiagnosticsPolicy(...).
Write diagnostics for the client. Never put secret input values, credentials, file paths, or host names in one, and keep exception messages, which stay in the server log, separate from them. A raw MethodInvocationHandler reports through the context it is invoked with: when its AccessContext is a CallContext, call report(...) or reportArguments(...) on that context's getOperationDiagnostics(). Argument diagnostics it puts directly in its CallMethodResult cannot refer to the response's strings, so only their additional info and inner status codes reach the client.
The service authorizes each call before dispatch, but direct handler calls bypass that check, so code that invokes a handler itself must check access first. Application exceptions, access denial, wrong Object ownership, and invalid arguments each produce a different status, so clients should check the service status and then each Call result.
| Status | Where it comes from | What it means |
|---|---|---|
Bad_UserAccessDenied |
Service authorization | The Session may not call this Method, for example because UserExecutable is false for that user. |
Bad_NodeIdUnknown |
Managed address space | The ObjectId does not exist. |
Bad_MethodInvalid |
Managed address space | The Method is not a component of the named Object, or the ObjectId does not name an Object. |
Bad_ArgumentsMissing |
SDK argument check | Fewer inputs than the required count. The handler does not run. |
Bad_TooManyArguments |
SDK argument check | More inputs than declared. The handler does not run. |
Bad_InvalidArgument |
SDK argument check or validateInputArgumentValues
|
At least one input failed. The input argument results say which and why, such as Bad_TypeMismatch or Bad_OutOfRange. |
Any status thrown from invoke
|
Your handler | The operation failed with that status, such as Bad_OutOfRange from AdjustSetpoint. |
Bad_InternalError |
AbstractMethodInvocationHandler or the address space |
The handler returned null, threw an InvalidArgumentException with the wrong number of results, or threw an exception other than UaException. The server logs each. |
Bad_ResourceUnavailable |
Managed address space | The Session already has the maximum number of Method calls in progress, 64 by default. The JVM system property milo.session.concurrentCallLimit sets the limit. |
A shared Method can serve several Object instances. For ObjectId-specific handlers, use application-owned MethodBindings instead of repeatedly replacing the default handler. bind validates Object ownership and typed argument metadata. Each MethodBinding token owns one registration, so closing an old token cannot remove its replacement. Condition dispatch has its own manager precedence, so use the Condition lifecycle for Condition Methods.
For a UaMethodNode subclass, ManagedAddressSpace Calls use getInvocationHandler(NodeId) and bypass an override of only getInvocationHandler(). Install the handler with setInvocationHandler(...), or override the NodeId overload and customize the handler returned by super.getInvocationHandler(objectId). See method-handler migration.
Close binding tokens or remove Object registrations before deleting owners, and close the registry on namespace shutdown. Cleanup does not delete nodes, cancel in-flight invocations, or wait for callbacks already selected, so keep handler dependencies alive until those calls finish.
Handlers run on server worker threads, and calls from different Sessions, or from separate requests on one Session, can run at the same time, so guard any state that handlers share. AdjustSetpoint reads Setpoint and then writes it, which is enough for a tutorial, but a handler for a real device should apply a relative adjustment as one device operation. Bound device and network work, and design retries around whether the operation is idempotent, because a client timeout does not prove the server skipped the command.
AdjustSetpoint now changes Setpoint and reports every failure with a precise status and an explanation. To tell clients when Setpoint changes through an event with Thermostat as its source, continue with Events.
Related: Server SDK · Calling methods · Types and instantiation
User guide for Milo 1.2.0-RC1 at source 69cb25087, 2026-10-12. Home · Getting started · Examples · Troubleshooting · Release notes
- Home
- Start here
- Client guide
- Server guide
- Feature guides
- Reference
- Operations
- Examples
- Release notes
- Contributing