/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.ISA95.Server.Providers; using Opc.Ua.Machinery.Server; using Opc.Ua.MachineTool; using Opc.Ua.MachineTool.Server; using Opc.Ua.MachineTool.WMTP; using Opc.Ua.Server; using Opc.Ua.Server.FileSystem; using Opc.Ua.Server.Hosting; using V2 = Opc.Ua.ISA95.JobControl.V2; namespace MachineToolSample.Server { /// /// Supplies explicit simulation observations while the existing host owns address-space and service lifetimes. /// internal sealed class DemoApplication : IServerStartupTask, IAsyncDisposable { /// /// Borrows host services and keeps equipment/storage isolated per configured instance. /// public DemoApplication( ServerOptions options, TimeProvider clock, ITelemetryContext telemetry, IWmtpAuthorization authorization) { m_options = options; m_clock = clock; m_telemetry = telemetry; m_authorization = authorization; m_logger = telemetry.CreateLogger(); } /// /// Gets completion of actual OPC UA startup, not merely generic-host construction. /// public Task Ready => m_ready.Task; /// /// Adds only the explicitly selected families to one existing namespace owner. /// public async ValueTask ConfigureAsync(IMachineryBuildContext context, CancellationToken cancellationToken) { for (int index = 1; index <= m_options.Instances; index++) { if ((m_options.Parts & MachineToolParts.MachineTool) != 0) { await AddMachineAsync(context, FormattableString.Invariant($"Machine{index}"), cancellationToken) .ConfigureAwait(false); } if ((m_options.Parts & MachineToolParts.Wmtp) != 0) { await AddPeripheralAsync( context, FormattableString.Invariant($"Peripheral{index}"), cancellationToken) .ConfigureAwait(false); } } } /// public ValueTask OnServerStartedAsync(IServerContext server, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); m_ready.TrySetResult(true); return default; } /// /// Acquires explicit samples and drains every accepted job revision in order until the bounded window ends. /// public async Task RunAsync(CancellationToken cancellationToken) { DateTimeOffset end = m_clock.GetUtcNow().AddSeconds(m_options.Seconds); while (m_clock.GetUtcNow() < end) { cancellationToken.ThrowIfCancellationRequested(); foreach (MachineInstance machine in m_machines) { if (machine.Jobs != null) { await machine.Jobs.SetMonitoringAsync(Monitoring(), cancellationToken).ConfigureAwait(false); await SimulateJobAsync(machine, cancellationToken).ConfigureAwait(false); await machine.Jobs.DrainAsync(machine.Runtime.RefreshAsync, cancellationToken) .ConfigureAwait(false); } else { await machine.Provider!.SetSnapshotAsync(Monitoring(), cancellationToken).ConfigureAwait(false); await machine.Runtime.RefreshAsync(cancellationToken).ConfigureAwait(false); } } foreach (WmtpPeripheral peripheral in m_peripherals) { try { await peripheral.RefreshAsync(cancellationToken).ConfigureAwait(false); } catch (ServiceResultException error) when (error.StatusCode == StatusCodes.BadServerTooBusy) { m_logger.AcquisitionDeferred(peripheral.NodeId); } } await TimeProviderExtensions.Delay(m_clock, TimeSpan.FromMilliseconds(200), cancellationToken) .ConfigureAwait(false); } } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } var failures = new List(); for (int index = m_resources.Count - 1; index >= 0; index--) { try { await m_resources[index].DisposeAsync().ConfigureAwait(false); } catch (Exception error) { failures.Add(error); } } foreach (string root in m_fileRoots) { try { Directory.Delete(root, recursive: true); } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { failures.Add(error); } } if (failures.Count != 0) { throw new AggregateException("Simulation resources did not all close cleanly.", failures); } } private async ValueTask AddMachineAsync( IMachineryBuildContext context, string name, CancellationToken cancellationToken) { MachineToolBuilder builder = context.AddMachineTool(name) .WithIdentification(identity => { identity.Manufacturer = new LocalizedText("OPC Foundation simulation"); identity.SerialNumber = name; identity.ProductInstanceUri = "urn:machinetool-sample:" + name; }); InMemoryMachineToolProvider? provider = null; MachineToolJobSource? jobs = null; if (m_options.Advanced) { jobs = new MachineToolJobSource(new MachineToolJobSourceOptions { Jobs = [new MachineToolJobSourceDescriptor { JobOrderId = DemoContract.JobOrderId, WorkMasterId = DemoContract.WorkMasterId, ProgramName = DemoContract.ProgramName, RunsPlanned = 1, PartSetKey = "DemoParts", PartSetName = "Simulated part set", PartsPlannedPerRun = 1 }], MaximumJobOrders = 4, MaximumJobResponses = 4 }, Monitoring(), m_clock, m_telemetry); m_resources.Add(jobs); builder.WithProvider(jobs) .WithProduction(new MachineToolProductionOptions { Features = MachineToolFeatures.Production | MachineToolFeatures.DynamicProductionPlan | MachineToolFeatures.ProductionParts }) .WithJobManagement(new MachineToolJobManagementOptions { Receiver = new AuthorizedReceiver(jobs, m_options.AllowCommands && !m_options.RefuseCommands), ResponseProvider = jobs, Catalog = jobs }); string root = Path.Combine(Path.GetTempPath(), "mt-demo-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(root, "WorkMasters")); m_fileRoots.Add(root); using (var output = new FileStream(Path.Combine(root, "WorkMasters", DemoContract.ProgramName), FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true)) { byte[] bytes = Encoding.UTF8.GetBytes(DemoContract.ProgramContents); #if NETFRAMEWORK await output.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); #else await output.WriteAsync(bytes.AsMemory(), cancellationToken).ConfigureAwait(false); #endif } builder.WithFileSystem( new PhysicalFileSystemProvider(root, isWritable: false), includeWorkMasters: true); } else { provider = new InMemoryMachineToolProvider(Monitoring()); builder.WithProvider(provider); } MachineToolRuntime runtime = await builder.BuildAsync(cancellationToken) .ConfigureAwait(false); m_resources.Add(runtime); m_machines.Add(new MachineInstance(name, runtime, provider, jobs)); } private async ValueTask AddPeripheralAsync( IMachineryBuildContext context, string name, CancellationToken cancellationToken) { var provider = new DemoPeripheral(m_options, m_clock); WmtpPeripheralBuilder builder = context.AddWirelessPeripheral(name) .WithProvider(provider).WithAuthorization(m_authorization) .WithIdentification(identity => { identity.Manufacturer = new LocalizedText("OPC Foundation simulation"); identity.SerialNumber = name; identity.ProductInstanceUri = "urn:machinetool-sample:" + name; }) .WithByteLimits(1024, 65536); if (m_options.Advanced) { builder.WithFeatures(WmtpFeatures.All); foreach (WmtpMeasurementKind kind in DemoPeripheral.MeasurementKinds) { builder.AddMeasurement(DemoPeripheral.Definition(kind, advanced: true)); } builder.WithWorkRecords(await CreateRecordsAsync(false, cancellationToken).ConfigureAwait(false), WmtpRecordFeatures.All); builder.WithServiceRecords(await CreateRecordsAsync(true, cancellationToken).ConfigureAwait(false), WmtpRecordFeatures.All); } WmtpPeripheral peripheral = await builder.BuildAsync(cancellationToken).ConfigureAwait(false); m_resources.Add(peripheral); m_peripherals.Add(peripheral); } private async ValueTask CreateRecordsAsync( bool service, CancellationToken cancellationToken) { var store = new InMemoryWmtpRecordStore(m_telemetry, capacity: 3, maximumReportBytes: 65536, timeProvider: m_clock); m_resources.Add(store); WmtpMeasurementDefinition definition = DemoPeripheral.Definition(WmtpMeasurementKind.Temperature, true); for (uint index = 1; index <= 3; index++) { await store.AppendAsync(new WMTPOutputDataType { EngineeringUnits = definition.EngineeringUnits, ActualValue = 20 + index, TypeOfMeasurement = (uint)definition.Kind, TypeOfSample = (uint)WmtpSampleKind.Instantaneous, InstrumentRange = definition.InstrumentRange, EURange = definition.EURange, ValuePrecision = definition.ValuePrecision, Definition = definition.Definition, SignalTag = service ? "service-temperature" : "work-temperature", Timestamp = m_clock.GetUtcNow().AddSeconds((int)index - 4), Index = index, MeasurementPeriod = 50, InternalUpdateInterval = 25 }, cancellationToken).ConfigureAwait(false); } return store; } private async ValueTask SimulateJobAsync(MachineInstance machine, CancellationToken cancellationToken) { if (machine.Name != m_options.SimulateJob || m_simulated.Contains(machine.Name)) { return; } MachineToolJobSource source = machine.Jobs!; MachineToolJobSourceCapture capture = await source.CaptureAsync(cancellationToken).ConfigureAwait(false); if (capture.JobOrders.Count == 0 || capture.JobOrders[0].State[0].StateNumber != 2) { return; } Isa95JobOrderReceiptV2 receipt = await source.TransitionAsync( DemoContract.JobOrderId, Isa95JobExecutionTransition.BeginExecution, cancellationToken) .ConfigureAwait(false); if (receipt.ReturnStatus != Isa95JobReturnStatus.Success) { throw new ServiceResultException(StatusCodes.BadInvalidState, receipt.Result.ToString()); } await source.RecordPartsAsync(DemoContract.JobOrderId, new MachineToolJobSourcePartCounts(1, 1, new MachineToolSampleInfo(m_clock.GetUtcNow(), StatusCodes.Good)), cancellationToken) .ConfigureAwait(false); await source.CompleteRunAsync(DemoContract.JobOrderId, cancellationToken).ConfigureAwait(false); m_simulated.Add(machine.Name); Console.WriteLine($"SIMULATED_JOB_COMPLETED machine={machine.Name}; no CNC execution."); } private MachineToolSnapshot Monitoring() => new() { Operation = new MachineToolOperation { Mode = MachineOperationMode.Automatic, Sample = new MachineToolSampleInfo(m_clock.GetUtcNow(), StatusCodes.Good) } }; /// /// Keeps one machine's provider and coherent job source separate from all sibling equipment. /// private sealed record MachineInstance( string Name, MachineToolRuntime Runtime, InMemoryMachineToolProvider? Provider, MachineToolJobSource? Jobs); /// /// Uses the existing ISA95 engine while requiring explicit server-side consent for simulated controls. /// private sealed class AuthorizedReceiver(MachineToolJobSource source, bool enabled) : IIsa95JobOrderReceiverV2 { /// public ValueTask ReceiveJobOrderAsync( Isa95JobOrderOperationV2 operation, V2.ISA95JobOrderDataType jobOrder, ArrayOf comment = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return enabled ? source.ReceiveJobOrderAsync(operation, jobOrder, comment, cancellationToken) : throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "This sample requires explicit server consent to accept simulated job controls."); } } private readonly ServerOptions m_options; private readonly TimeProvider m_clock; private readonly ITelemetryContext m_telemetry; private readonly IWmtpAuthorization m_authorization; private readonly ILogger m_logger; private readonly List m_machines = []; private readonly List m_peripherals = []; private readonly List m_resources = []; private readonly List m_fileRoots = []; private readonly HashSet m_simulated = new(StringComparer.Ordinal); private readonly TaskCompletionSource m_ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private int m_disposed; } /// /// Makes expected peripheral backpressure explicit without retrying or aborting a caller's device operation. /// internal static partial class DemoApplicationLog { /// /// Retains the previous acquired value and normal stale-age policy until the next acquisition tick. /// [LoggerMessage(EventId = MachineToolServerEventIds.DemoApplication, Level = LogLevel.Information, Message = "Acquisition deferred for {PeripheralId}: device operation active; retrying next normal tick.")] public static partial void AcquisitionDeferred(this ILogger logger, NodeId peripheralId); } }