Skip to content

First Server

Kevin Herron edited this page Oct 12, 2026 · 11 revisions

Applies to Milo 1.2.0-RC1. Source baseline 69cb25087, 2026-10-12.

In this tutorial you turn the thermostat from OPC UA concepts into a running OPC UA server. It has a Temperature reading, a Setpoint that clients can change, and an AdjustSetpoint Method that moves the setpoint by a given amount. The client pages use this server as their running example, and the server pages explain how its parts work and then extend it. The program builds in the project from Getting started, which already has both SDK dependencies.

The server binds only to IPv4 loopback at opc.tcp://127.0.0.1:12686/wiki. Its endpoint accepts anonymous users with SecurityPolicy.None, which gives you no channel encryption and no peer authentication, so keep it on loopback. Before you expose a server on a network, provision certificates, trust, and authorization with Server security and Access control.

What you will build

The thermostat is one Object with three components, organized under the standard Objects folder so clients can find it by browsing.

Objects
└── Thermostat         Object
    ├── Temperature    Variable, Double, read-only, always 21.5
    ├── Setpoint       Variable, Double, read and write, starts at 22.0
    └── AdjustSetpoint Method, input Delta (Double), output Setpoint (Double)

Each Node uses its name as a String identifier in the namespace urn:eclipse:milo:wiki, so Setpoint's NodeId is the identifier Setpoint in that namespace. The server assigns the namespace's numeric index, which is why clients look the index up by URI instead of hard-coding it.

Grouping the Variables and the Method under one Object mirrors the device they describe, so a client that finds Thermostat can browse to everything that belongs to it.

The complete program

Save this program as src/main/java/org/eclipse/milo/examples/wiki/FirstServer.java.

package org.eclipse.milo.examples.wiki;

import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.eclipse.milo.opcua.sdk.core.AccessLevel;
import org.eclipse.milo.opcua.sdk.core.Reference;
import org.eclipse.milo.opcua.sdk.core.ValueRanks;
import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
import org.eclipse.milo.opcua.sdk.server.ManagedNamespaceWithLifecycle;
import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig;
import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler;
import org.eclipse.milo.opcua.sdk.server.methods.InvalidArgumentException;
import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode;
import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode;
import org.eclipse.milo.opcua.stack.core.NodeIds;
import org.eclipse.milo.opcua.stack.core.Stack;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager;
import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
import org.eclipse.milo.opcua.stack.core.types.structured.Argument;
import org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransport;
import org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransportConfig;

/** A loopback-only thermostat server for the Wiki tutorials. */
public final class FirstServer {

  public static final String NAMESPACE_URI = "urn:eclipse:milo:wiki";

  private FirstServer() {}

  /**
   * Create a server that owns its tutorial namespace.
   *
   * @param port the loopback TCP port to bind.
   * @return an unstarted server; its caller must shut it down, including after failed startup.
   */
  public static OpcUaServer create(int port) {
    var endpoint =
        EndpointConfig.newBuilder()
            .setBindAddress("127.0.0.1")
            .setHostname("127.0.0.1")
            .setBindPort(port)
            .setPath("/wiki")
            .setSecurityPolicy(SecurityPolicy.None)
            .setSecurityMode(MessageSecurityMode.None)
            .addTokenPolicy(OpcUaServerConfig.USER_TOKEN_POLICY_ANONYMOUS)
            .build();

    var config =
        OpcUaServerConfig.builder()
            .setApplicationUri("urn:eclipse:milo:wiki:server")
            .setApplicationName(LocalizedText.english("Wiki server"))
            .setProductUri("urn:eclipse:milo:wiki")
            .setCertificateManager(new DefaultCertificateManager())
            .setEndpoints(Set.of(endpoint))
            .build();

    var server =
        new OpcUaServer(
            config,
            profile -> new OpcTcpServerTransport(OpcTcpServerTransportConfig.newBuilder().build()));

    server.addLifecycleParticipant(new TutorialNamespace(server));
    return server;
  }

  public static void main(String[] args) throws Exception {
    int port = args.length == 0 ? 12686 : Integer.parseInt(args[0]);
    OpcUaServer server = create(port);
    try {
      server.startup().get(10, TimeUnit.SECONDS);
      System.out.println(
          "Listening on opc.tcp://127.0.0.1:" + port + "/wiki; press Enter to stop.");
      System.in.read();
    } finally {
      try {
        server.shutdown().get(10, TimeUnit.SECONDS);
      } finally {
        Stack.releaseSharedResources();
      }
    }
  }

  private static final class TutorialNamespace extends ManagedNamespaceWithLifecycle {
    TutorialNamespace(OpcUaServer server) {
      super(server, NAMESPACE_URI);

      // The Thermostat Object, organized under the Objects folder.
      UaObjectNode thermostat =
          new UaObjectNode.UaObjectNodeBuilder(getNodeContext())
              .setNodeId(newNodeId("Thermostat"))
              .setBrowseName(newQualifiedName("Thermostat"))
              .setDisplayName(LocalizedText.english("Thermostat"))
              .setTypeDefinition(NodeIds.BaseObjectType)
              .build();
      getNodeManager().addNode(thermostat);

      thermostat.addReference(
          new Reference(
              thermostat.getNodeId(),
              NodeIds.Organizes,
              NodeIds.ObjectsFolder.expanded(),
              Reference.Direction.INVERSE));

      // Temperature and Setpoint, components of Thermostat.
      UaVariableNode temperature =
          new UaVariableNode.UaVariableNodeBuilder(getNodeContext())
              .setNodeId(newNodeId("Temperature"))
              .setBrowseName(newQualifiedName("Temperature"))
              .setDisplayName(LocalizedText.english("Temperature"))
              .setDataType(NodeIds.Double)
              .setTypeDefinition(NodeIds.BaseDataVariableType)
              .setAccessLevel(AccessLevel.READ_ONLY)
              .setUserAccessLevel(AccessLevel.READ_ONLY)
              .setValue(new DataValue(new Variant(21.5)))
              .build();
      getNodeManager().addNode(temperature);
      thermostat.addComponent(temperature);

      UaVariableNode setpoint =
          new UaVariableNode.UaVariableNodeBuilder(getNodeContext())
              .setNodeId(newNodeId("Setpoint"))
              .setBrowseName(newQualifiedName("Setpoint"))
              .setDisplayName(LocalizedText.english("Setpoint"))
              .setDataType(NodeIds.Double)
              .setTypeDefinition(NodeIds.BaseDataVariableType)
              .setAccessLevel(AccessLevel.READ_WRITE)
              .setUserAccessLevel(AccessLevel.READ_WRITE)
              .setValue(new DataValue(new Variant(22.0)))
              .build();
      getNodeManager().addNode(setpoint);
      thermostat.addComponent(setpoint);

      // The AdjustSetpoint 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);
    }
  }

  /** 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) {
        StatusCode outOfRange = new StatusCode(StatusCodes.Bad_OutOfRange);
        throw new InvalidArgumentException(new StatusCode[] {outOfRange});
      }
    }

    @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) {
          throw new UaException(StatusCodes.Bad_OutOfRange);
        }

        setpoint.setValue(new DataValue(new Variant(adjusted)));

        return new Variant[] {new Variant(adjusted)};
      }
    }
  }
}

How the program works

The program has four parts. create() configures the server and its endpoint, TutorialNamespace builds the thermostat's Nodes, AdjustSetpointHandler implements the Method, and main() starts the server and keeps it running until you stop it.

The server and its endpoint

create() describes one endpoint with EndpointConfig, which binds to 127.0.0.1 and also advertises 127.0.0.1 as its hostname, so the endpoint URL that clients discover is exactly opc.tcp://127.0.0.1:12686/wiki.

OpcUaServerConfig adds the application identity (application URI, name, and product URI), the endpoint, and an empty DefaultCertificateManager. The manager can stay empty because a None endpoint needs no application certificate. The OpcUaServer constructor then takes that configuration and a function that creates the OPC UA TCP transport.

Finally, create() passes the namespace to addLifecycleParticipant() and returns the server unstarted. From then on the server owns the namespace lifecycle, including its registration and sampling, so starting the server brings the namespace up and shutting it down takes the namespace down. Do not also start or shut down the namespace yourself.

The tutorial namespace

TutorialNamespace extends ManagedNamespaceWithLifecycle. Its constructor registers urn:eclipse:milo:wiki in the server's namespace table, which assigns the namespace index, and newNodeId() and newQualifiedName() then build NodeIds and browse names in that namespace. Every Node goes into the namespace's UaNodeManager, which getNodeManager() returns. The rest of the constructor builds the thermostat in three steps.

The Thermostat Object

A UaObjectNode.UaObjectNodeBuilder creates Thermostat with the type definition BaseObjectType, the most general Object type. Adding the Node to the node manager stores it, but a client still needs a reference to reach it by browsing, so the program adds an inverse Organizes reference from Thermostat to the Objects folder. Milo stores the matching forward reference from Objects as well, so a browsing client sees Objects Organizes Thermostat.

Temperature and Setpoint

A UaVariableNode.UaVariableNodeBuilder creates each Variable as a scalar Double with the type definition BaseDataVariableType. The builder's default ValueRank is scalar, so the program does not set it. The Variables differ in access. Temperature sets AccessLevel and UserAccessLevel to READ_ONLY, so the server rejects writes to it, while Setpoint uses READ_WRITE. The tutorial server accepts any Double written to Setpoint, and Data access later adds a filter that limits writes to the range 0 to 100.

thermostat.addComponent() adds a HasComponent reference from Thermostat to each Variable, along with its inverse. That is what makes the Variables appear when a client browses Thermostat, and it gives each one a browse path, for example from Objects to Thermostat to Setpoint.

The AdjustSetpoint Method

UaMethodNode.builder() creates the Method Node. Its Executable and UserExecutable attributes default to true, so clients may call it. bindInvocationHandler() installs the handler and publishes the handler's argument declarations as the Method's InputArguments and OutputArguments Properties, which is how clients learn the Method's signature.

The last step makes AdjustSetpoint a component of Thermostat. A Call request names both an ObjectId and a MethodId, and Milo finds the Method through the Object's HasComponent references. Clients therefore call AdjustSetpoint with ObjectId Thermostat.

The AdjustSetpoint handler

AdjustSetpointHandler extends AbstractMethodInvocationHandler, which checks the count, shape, and data type of a call's inputs against getInputArguments() before your code runs. A call that sends a String where Delta should be a Double fails there with Bad_InvalidArgument, and Delta's input-argument result is Bad_TypeMismatch. The handler adds two rules of its own.

validateInputArgumentValues() rejects a Delta that is NaN, infinite, or null. The SDK's type check lets a null value through, so the handler uses instanceof Double instead of a cast. For a rejected Delta it throws InvalidArgumentException with Bad_OutOfRange for that input, so the client receives Bad_InvalidArgument for the call and can see which input was wrong.

invoke() runs only with a valid Delta. While holding the Setpoint node's lock, which a client write also takes when it stores a value, it reads the current setpoint, adds Delta, and checks that the result stays between 0.0 and 100.0. The lock keeps a concurrent write or call from changing Setpoint in between. A result outside that range throws UaException with Bad_OutOfRange, which fails the call and leaves Setpoint unchanged. Otherwise the handler stores the new value with setValue() and returns it as the Method's one output. Because the handler changes the Setpoint Node itself, a client subscribed to Setpoint receives the new value just as it would after a Write.

Run and check the server

After compiling with the command in Getting started, run this in the application directory.

java -cp "target/classes:$(cat target/classpath.txt)" org.eclipse.milo.examples.wiki.FirstServer

Run it in the foreground with standard input attached, because the program waits for Enter. main() uses port 12686 unless you pass another port as the first argument, and it waits up to ten seconds for startup. It then prints Listening on opc.tcp://127.0.0.1:12686/wiki; press Enter to stop. and serves clients until you stop it.

Leave it running. Your first client connects from another terminal and prints Temperature: 21.5. You can also connect any OPC UA client with anonymous identity and SecurityPolicy None, browse the Objects folder to find Thermostat, and try the operations below. The client pages rely on these results.

Operation Result
Read Temperature Good, 21.5
Read Setpoint Good, the current setpoint (22.0 until something changes it)
Write a Double to Setpoint Good. The tutorial server accepts any Double.
Write a String to Setpoint Bad_TypeMismatch
Write Temperature Bad_NotWritable, because its AccessLevel lacks CurrentWrite
Read a NodeId the server does not have Bad_NodeIdUnknown
Call AdjustSetpoint on Thermostat with Delta 1.5 Good. With Setpoint at 22.0, the output is 23.5 and Setpoint becomes 23.5.
Call AdjustSetpoint with a Delta that moves Setpoint outside 0.0 to 100.0 Bad_OutOfRange. Setpoint keeps its value.
Call AdjustSetpoint with a Delta of NaN, infinity, or null Bad_InvalidArgument, with Bad_OutOfRange as Delta's input-argument result
Call AdjustSetpoint with a String Delta Bad_InvalidArgument, with Bad_TypeMismatch as Delta's input-argument result

When only UserAccessLevel is read-only, a write returns Bad_UserAccessDenied instead of Bad_NotWritable. Access control explains how user access is decided.

Stop the server

Press Enter in the server terminal to stop gracefully. End of input follows the same path. The program waits for shutdown and then releases Milo's process-wide shared resources. In an application with other Milo components, defer that release until they have all stopped.

Ctrl+C stops the JVM without running the finally block. The program registers no shutdown hook, so Milo's graceful shutdown does not run, although the operating system still releases the sockets.

A stopped or failed server instance cannot be restarted, so call create() again for a new one. The thermostat's values live in its Nodes in the namespace's NodeManager, so there is no separate device connection or scheduler to stop.

When things go wrong

If another process owns port 12686, the server logs Failed to bind endpoint at WARN. The startup wait then throws an ExecutionException caused by a UaException with Bad_ConfigurationError and the message No endpoints bound, and the finally block still shuts the server down. Stop the conflicting process, or pass a free port as the program's first argument and use the matching client URL.

The ten-second waits in main() do not bound every delay. Startup and shutdown do their work on the calling thread in this sequence, so a stalled bind or lifecycle callback blocks before the future is returned.

Examples and reference

Server configuration explains endpoint and identity settings. Address space explains namespace registration and references, and Methods covers handlers in more depth. Data access replaces constant values with application or device data.

The Wiki examples module contains the complete program.

Next steps

Leave the server running and continue with Your first client, which connects to it and reads Temperature.

Related: Getting started · Server SDK · Server configuration · Address space · Methods · Deployment

Clone this wiki locally