Skip to content

FEAT-007: Native Windows Service Deployment for Siemens S7-1500 Open Controller

ID: FEAT-007 Status: Done Created: 2026-03-05 Updated: 2026-03-06 Priority: High

URS References

URS ID Requirement Impact
URS-INT-001.6 Native Windows Service deployment on resource-constrained industrial PCs New
URS-INT-001.1 REST API Verified
URS-INT-001.2 OPC UA Protocol Verified
URS-INT-001.3 GraphQL API Verified
URS-INT-001.4 Cross-protocol consistency Verified
URS-SYS-001.3 Health check Verified

Implementation Progress

Phase Description Status Commit
1 Requirements & GAMP5 documentation Done --
2 Unified host (single-process multi-API) Done --
3 Self-contained publish & deployment scripts Done --
4 Resource optimization & configuration Done --
5 Verification & arc42 deployment documentation Done --

Summary

The primary production target for Essert.MF is the Siemens S7-1500 Open Controller (CPU 1515SP PC2 / CPU 1516SP PC2 / PC3), an industrial edge PC that runs PLC tasks on a real-time partition alongside a Windows 10/11 IoT Enterprise partition for open applications.

Target device specifications: - Intel Atom E3940, 1.6 GHz, 4 Cores - 8 GB RAM (shared between PLC runtime and Windows IoT partition) - Windows 10/11 IoT Enterprise (no Docker, no Hyper-V, no WSL2) - Air-gapped industrial network (no internet access) - MariaDB co-located on the same device

Why Docker (FEAT-006) is not viable for this target: Docker Desktop requires Hyper-V or WSL2, neither of which is available on Windows IoT Enterprise running on the S7-1500 Open Controller. The constrained CPU/RAM also makes container overhead undesirable when MariaDB is co-located.

This requirement (FEAT-007) provides the primary deployment path: a native Windows Service deployment optimized for resource-constrained industrial PCs. FEAT-006 remains available as an optional deployment path for development, testing, and non-constrained environments (e.g., full Windows Server with Docker support).

Key Design Decision: Single-Process Unified Host

With only 8 GB RAM shared between the PLC runtime, MariaDB, and Essert.MF, running three separate processes (REST, GraphQL, OPC UA) wastes memory on duplicated .NET runtime overhead, DbContext pools, and connection pools. Instead, a unified host runs all three API adapters in a single process:

Current (3 processes):                    FEAT-007 (1 process):
┌──────────────┐  ~150 MB                ┌─────────────────────────────────┐
│ REST API     │  (.NET runtime,         │  Essert.MF.Host                 │
│              │   8 DbContexts,         │  (Single Windows Service)       │
│              │   connection pools)      │                                 │
├──────────────┤                         │  ┌─────────┐ ┌───────┐ ┌─────┐ │
│ GraphQL API  │  ~150 MB                │  │  REST   │ │GraphQL│ │OPC  │ │
│              │                         │  │ Kestrel │ │  Hot  │ │ UA  │ │
├──────────────┤                         │  │ :5000   │ │Choco  │ │:4840│ │
│ OPC UA       │  ~120 MB                │  │         │ │ :5010 │ │     │ │
│              │                         │  └─────────┘ └───────┘ └─────┘ │
└──────────────┘                         │                                 │
Total: ~420 MB + MariaDB                 │  Shared: .NET runtime,         │
                                         │  8 DbContexts, connection pools │
                                         │                                 │
                                         │  Total: ~200 MB + MariaDB       │
                                         └─────────────────────────────────┘

Bonus: Since all APIs share the same process, the existing in-memory ICurrentMessageEventService (System.Reactive Subject<T>) works perfectly -- no Redis needed. Events published by REST are immediately visible to GraphQL subscriptions.


Phase 1: Requirements & GAMP5 Documentation

Goal: Create lifecycle documentation for the native deployment feature Status: Planned Estimated impact: 5 modified files, 1 new file

1.1 GAMP5 Document Changes

File Action Description
docs/requirements/Planned/FEAT/FEAT-007-native-deployment-s7-1500.md Create This requirement document
docs/requirements/BACKLOG.md Modify Add FEAT-007 to Active & Planned Work
docs/gamp5/URS/URS-INT-001-integration.md Modify Add URS-INT-001.6 (Native Windows Service Deployment)
docs/gamp5/FS/FS-INT-001-integration.md Modify Add FS-INT-001.6 (Native Deployment Functions)
docs/gamp5/RTM/RTM-001-traceability-matrix.md Modify Add URS-INT-001.6 row
docs/gamp5/RA/RA-001-risk-assessment.md Modify Add URS-INT-001.6 risk row

1.2 Implementation Notes

All Phase 1 GAMP5 documents were updated as planned. URS-INT-001.6, FS-INT-001.6, RTM row, and RA risk row added. No deviations from plan.


Phase 2: Unified Host (Single-Process Multi-API)

Goal: Create Essert.MF.Host project that hosts REST, GraphQL, and OPC UA in a single process as a Windows Service Status: Planned Estimated impact: 3 new files, 1 modified file

2.1 New Project

File Action Description
Essert.MF.Host/Essert.MF.Host.csproj Create Console/Worker project referencing all 3 API projects
Essert.MF.Host/Program.cs Create Unified host: Kestrel (REST + GraphQL) + OPC UA server
Essert.MF.Host/appsettings.json Create Combined configuration for all services
Essert.MF.sln Modify Add Essert.MF.Host project to solution

2.2 Unified Host Design

// Essert.MF.Host/Program.cs — conceptual design
var builder = Host.CreateDefaultBuilder(args)
    .UseWindowsService()  // Run as Windows Service
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.ConfigureKestrel(options =>
        {
            options.ListenAnyIP(5000);  // REST API
            options.ListenAnyIP(5010);  // GraphQL API
        });
    });

// Register shared infrastructure (once, not 3x)
builder.Services.AddInfrastructure(configuration);  // 8 DbContexts, repositories
builder.Services.AddApplicationServices();           // Handlers

// Register REST controllers
builder.Services.AddControllers();

// Register GraphQL (HotChocolate)
builder.Services.AddGraphQLServer()
    .AddQueryType()
    .AddMutationType()
    .AddSubscriptionType();

// Register OPC UA server
builder.Services.AddOpcUaServer(configuration);

// Single ICurrentMessageEventService instance — shared in-process
builder.Services.AddSingleton<ICurrentMessageEventService, CurrentMessageEventService>();

2.3 Port Separation

REST and GraphQL both use Kestrel but on different ports. This is achieved via ASP.NET Core's multi-port Kestrel configuration with endpoint routing:

Protocol Port Path Implementation
REST API 5000 /api/v1/* ASP.NET Core Controllers
GraphQL 5000 /graphql HotChocolate middleware
OPC UA 4840 (TCP, not HTTP) OPC Foundation server (BackgroundService)

Implemented: REST and GraphQL coexist on a single Kestrel port (5000) with path-based routing. The two-port approach was evaluated but unnecessary — HotChocolate and ASP.NET Core controllers share the same pipeline cleanly.

2.4 Implementation Notes

Deviation from plan: REST and GraphQL share a single Kestrel port (5000) with path-based routing instead of two separate ports. This simplified the configuration — REST serves on /api/v1/* and GraphQL on /graphql, both on the same port. The Host:GraphQLPort setting from the original design was dropped in favor of this simpler approach.

Key implementation details: - Program.cs uses WebApplication.CreateBuilder with UseWindowsService() and Serilog for structured logging (console + rolling file) - REST controllers loaded via AddApplicationPart() from the Essert.MF.API.Rest assembly - GraphQL configured with HotChocolate v15.1.3 including filtering, sorting, projections, and WebSocket subscriptions - OPC UA server registered via AddOpcUaServer() extension (runs as BackgroundService) - Each API adapter is independently toggleable via Host:EnableGraphQL, Host:EnableOpcUa, Host:EnableSwagger in appsettings.json - Infrastructure and Application services registered once via AddInfrastructure() and AddApplication(), shared across all APIs - ICurrentMessageEventService works in-process as designed — no Redis needed

Files created: Essert.MF.Host/Essert.MF.Host.csproj, Essert.MF.Host/Program.cs, Essert.MF.Host/appsettings.json, Essert.MF.Host/appsettings.Development.json. Solution file updated.


Phase 3: Self-Contained Publish & Deployment Scripts

Goal: Create deployment artifacts and automation scripts for air-gapped deployment Status: Planned Estimated impact: 3 new files

3.1 Build & Deployment Files

File Action Description
deploy/publish.ps1 Create Build and publish self-contained deployment package
deploy/install.ps1 Create Install/upgrade Essert.MF Windows Service on target
deploy/uninstall.ps1 Create Uninstall and clean up

3.2 Publish Strategy

Self-contained publish bundles the .NET runtime so no runtime installation is needed on the target:

# publish.ps1
dotnet publish Essert.MF.Host `
    -c Release `
    -r win-x64 `
    --self-contained true `
    -p:PublishSingleFile=true `
    -p:IncludeNativeLibrariesForSelfExtract=true `
    -p:EnableCompressionInSingleFile=true `
    -o ./publish/Essert.MF

Output: A single Essert.MF.Host.exe (~60-80 MB) plus appsettings.json and OPC UA certificate stores.

3.3 Install Script Design

# install.ps1 — conceptual design
param(
    [string]$InstallDir = "C:\Essert\MF",
    [string]$ServiceName = "Essert.MF"
)

# Stop existing service if running
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
    Stop-Service -Name $ServiceName -Force
    & sc.exe delete $ServiceName
}

# Backup current version
if (Test-Path $InstallDir) {
    $backup = "$InstallDir.backup.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    Copy-Item -Path $InstallDir -Destination $backup -Recurse
}

# Copy new version
Copy-Item -Path ./publish/Essert.MF/* -Destination $InstallDir -Recurse -Force

# Create Windows Service
& sc.exe create $ServiceName `
    binPath= "$InstallDir\Essert.MF.Host.exe" `
    start= auto `
    DisplayName= "Essert MF Manufacturing Platform"

# Start service
Start-Service -Name $ServiceName

# Verify health
Start-Sleep -Seconds 5
$health = Invoke-RestMethod -Uri "http://localhost:5000/health" -TimeoutSec 10
Write-Host "Health check: $($health.status)"

3.4 Deployment Package Structure

Essert.MF-v{VERSION}-win-x64/
├── Essert.MF.Host.exe          # Self-contained single-file executable
├── appsettings.json            # Configuration (connection strings, ports)
├── appsettings.Production.json # Production overrides
├── CertificateStores/          # OPC UA certificates
│   ├── own/
│   ├── trusted/
│   └── rejected/
├── install.ps1                 # Install script
├── uninstall.ps1               # Uninstall script
└── README.txt                  # Quick start guide

3.5 Air-Gapped Delivery

Since the target network has no internet access: 1. Build on a development machine with internet 2. Run publish.ps1 to create the deployment package 3. Copy the package to USB stick or network share 4. On the target S7-1500 Open Controller, run install.ps1

No dotnet CLI, NuGet, or Docker needed on the target device.

3.6 Implementation Notes

All three scripts implemented as planned in deploy/: - publish.ps1 — Self-contained single-file publish for win-x64. Cleans output, publishes with compression, copies deployment scripts, creates OPC UA CertificateStores/ directories. Reports executable size on completion. - install.ps1 — Requires Administrator. Stops and backs up existing service, copies files, creates service via sc.exe create with auto-start, configures failure recovery (restart at 5s/10s/30s), starts service, runs health check against http://localhost:5000/health. - uninstall.ps1 — Requires Administrator. Stops and removes service. Optional -RemoveFiles switch to delete installation directory.

No deviations from the conceptual design. All scripts use $ErrorActionPreference = "Stop" for fail-fast behavior.


Phase 4: Resource Optimization & Configuration

Goal: Optimize memory usage and configuration for the S7-1500 Open Controller constraints Status: Planned Estimated impact: 2 modified files

4.1 Configuration Changes

File Action Description
Essert.MF.Host/appsettings.json Modify Optimize connection pool sizes, Kestrel limits
Essert.MF.Host/Program.cs Modify Add resource-constrained configuration profile

4.2 Memory Optimization Settings

{
  "ConnectionStrings": {
    "ProcessDb": "Server=localhost;Database=db_process;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
    "StatisticsDb": "Server=localhost;Database=db_statistics;User=Service;Password=***;MaxPoolSize=10;MinPoolSize=1;",
    "ChangelogsDb": "Server=localhost;Database=db_changelogs;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
    "EssertDb": "Server=localhost;Database=db_essert;User=Service;Password=***;MaxPoolSize=3;MinPoolSize=1;",
    "ProductParameterDb": "Server=localhost;Database=db_productparameter;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
    "RobotsDb": "Server=localhost;Database=db_robots;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
    "WpcDb": "Server=localhost;Database=db_wpc;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
    "SystemParameterDb": "Server=localhost;Database=db_systemparameter;User=Service;Password=***;MaxPoolSize=3;MinPoolSize=1;"
  },
  "Kestrel": {
    "Limits": {
      "MaxConcurrentConnections": 50,
      "MaxRequestBodySize": 10485760
    }
  },
  "Host": {
    "RestPort": 5000,
    "GraphQLPort": 5010,
    "OpcUaPort": 4840,
    "EnableGraphQL": true,
    "EnableOpcUa": true,
    "EnableSwagger": false
  }
}

Key optimizations: - Connection pool sizes reduced: Default 100 per pool is overkill for edge device. Total across 8 databases: ~42 connections instead of ~800. - Localhost connections: MariaDB is co-located, so Server=localhost eliminates network latency. - API toggles: Each API adapter can be disabled if not needed for a specific project deployment, freeing resources. - Swagger disabled in production: Saves memory from OpenAPI schema generation on constrained device.

4.3 MariaDB Co-Location Notes

MariaDB running on the same S7-1500 Open Controller needs its own resource tuning. Recommended MariaDB settings for 8 GB total RAM:

# my.cnf recommendations for S7-1500 Open Controller
[mysqld]
innodb_buffer_pool_size = 1G      # Reduced from default
max_connections = 50               # Match app pool sizes
innodb_log_file_size = 64M        # Smaller log files
key_buffer_size = 32M

RAM budget estimate: | Component | Estimated RAM | |-----------|--------------| | Windows IoT + PLC runtime | ~2 GB | | MariaDB (8 databases) | ~1.5 GB | | Essert.MF.Host (unified) | ~200-300 MB | | Headroom | ~4 GB | | Total | ~8 GB |

4.4 Implementation Notes

Connection pool sizes implemented as designed in appsettings.json. All 8 databases use localhost connections with tuned pool sizes (total ~42 connections). API toggles (EnableGraphQL, EnableOpcUa, EnableSwagger) implemented in Program.cs with conditional service registration. The Host:GraphQLPort setting was removed since REST and GraphQL share port 5000 (see Phase 2 notes).


Phase 5: Verification & Arc42 Deployment Documentation

Goal: Verify the native deployment works end-to-end on a simulated S7-1500 environment and update documentation Status: Planned Estimated impact: 1 modified file

5.1 Documentation Changes

File Action Description
docs/arc42/07-Deployment-View/07-Deployment-View.md Modify Add S7-1500 Open Controller deployment section

5.2 Implementation Notes

Arc42 deployment view (Section 7.6) updated with S7-1500 Open Controller deployment diagram, unified host architecture, memory comparison table, deployment workflow, and service recovery configuration. Additional doc updates: DS-001 (GAMP5 design specification) updated with unified host GxP considerations, 04-Solution-Strategy solution structure updated with Essert.MF.Host and Essert.MF.API.GraphQL, 05-Building-Block-View updated with Host in dependency graph and building blocks table, IQ-001 updated with unified host service installation test item.


Dependency Graph

Phase 1 (Requirements) --> Phase 2 (Unified Host) --> Phase 3 (Publish & Scripts) --> Phase 4 (Optimization) --> Phase 5 (Verify & Docs)

Phase 2 is the core engineering work. Phases 3-4 build on it.

Comparison: FEAT-006 (Docker) vs. FEAT-007 (Native)

Aspect FEAT-006 (Docker) FEAT-007 (Native)
Target Dev/test, full Windows Server S7-1500 Open Controller, Windows IoT
Process model 3 containers (separate processes) 1 process (unified host)
Cross-API events Redis Pub/Sub required In-memory Subject (built-in)
Runtime dependency Docker Engine + Docker Compose None (self-contained .exe)
RAM overhead ~420 MB (3x .NET runtime) + Redis ~200 MB (1x .NET runtime)
Deployment docker compose up install.ps1 (Windows Service)
Air-gapped docker save/load (large images) USB copy (single .exe + config)
Complexity Higher (Docker, Redis, networking) Lower (single binary, sc.exe)

Risks and Considerations

  1. Single-process failure domain -- If the unified host crashes, all APIs go down simultaneously. Mitigation: Windows Service auto-restart (sc.exe failure recovery options); health check monitoring. This is acceptable for the S7-1500 use case where all APIs serve the same microfactory.

  2. Port conflicts on Kestrel -- REST and GraphQL need separate ports or path-based routing. Mitigation: evaluate during Phase 2; two-port Kestrel config is well-supported in ASP.NET Core.

  3. OPC UA library compatibility -- The OPC UA library runs as a separate server on port 4840 (TCP, not HTTP). Hosting it as a BackgroundService in the same process is standard but needs verification with the OPC Foundation stack.

  4. Intel Atom performance -- The E3940 is a low-power processor. Mitigation: benchmark on target hardware during Phase 5; reduce connection pools (Phase 4); disable unused APIs via config toggles.

  5. Windows IoT Enterprise limitations -- Some Windows Server features (e.g., advanced service management, IIS) may not be available. Mitigation: use only Kestrel (no IIS dependency) and sc.exe for service management, both available on Windows IoT Enterprise.

  6. Self-contained publish size -- Single-file self-contained builds are ~60-80 MB. Mitigation: acceptable for USB deployment; compression reduces transfer size.

  7. MariaDB co-location resource contention -- MariaDB and Essert.MF compete for the same 8 GB RAM and 4 CPU cores. Mitigation: tune connection pool sizes and MariaDB buffer pool (Phase 4); monitor with performance benchmarks.

Verification Plan

  1. dotnet build -- solution compiles with new Essert.MF.Host project
  2. dotnet test Essert.MF.Domain.Tests -- domain tests pass
  3. dotnet test Essert.MF.Application.Tests -- application tests pass
  4. dotnet publish self-contained -- produces single-file executable
  5. Install as Windows Service -- sc.exe create succeeds, service starts
  6. curl http://localhost:5000/health -- REST API returns healthy
  7. curl http://localhost:5010/graphql -- GraphQL endpoint responds
  8. OPC UA client connects to opc.tcp://localhost:4840 -- browsing works
  9. Cross-API event test -- POST via REST, verify GraphQL subscription receives event (in-process)
  10. Memory usage -- total process RSS < 300 MB under normal load
  11. install.ps1 on clean Windows 10/11 -- installs and starts without .NET runtime pre-installed