Starter CMake-based C++ scaffold using CPM.cmake for dependencies, libhv as the application framework, cmrc for the embedded web application, and ReactivePlusPlus for reactive programming. Runtime events are logged with plog.
cmake/- CMake helper modulessrc/common/- shared utilities, including the Kotlin Flow-style APIsrc/mdns/- mDNS and DNS-SD discoverysrc/modbus/- Modbus device discoverysrc/shelly/- Shelly device discovery and representationsrc/sunspec/- SunSpec device discoverysrc/webapp/- web server code and the future Flutter web app
The content of src/webapp/ will become a Flutter application. For now,
src/webapp/index.html is embedded as a placeholder and served by
src/webapp/WebAppService.cpp. HTTP content and the echo WebSocket endpoint
/ws share port 8030.
src/common/flow.hpp provides a small, cold-flow facade over
ReactivePlusPlus:
using namespace neubau::common;
flowOf(1, 2, 3, 4)
.filter([](int value) { return value % 2 == 0; })
.map([](int value) { return value * 10; })
.onEach([](int value) { std::cout << value << '\n'; })
.collect([](int value) { consume(value); });Use from(iterable) to create a flow from a container. Each call to collect
starts a new subscription, matching Kotlin Flow's cold-stream behavior.
src/mdns/MdnsDiscovery.hpp provides generic DNS-SD discovery for any mDNS
service type:
neubau::mdns::MdnsDiscovery discovery;
discovery.services()
.collect([](const neubau::mdns::MdnsService& service) {
use(service.hostname, service.port, service.txt);
});
discovery.discover("_http._tcp");The member flow is hot and can be shared by external subscribers. Calling
discover() sends a PTR query requesting a unicast response through a libhv
UDP server. Responses include the service instance, endpoint, IPv4/IPv6
addresses, TXT metadata, and TTL.
src/common/Timer.hpp consumes the discovery and thing interval flows from a
common::ConfigRepository and exposes cancellable timer ticks aligned to Unix
epoch boundaries. Equal configured intervals share one libhv schedule:
neubau::common::Timer timer{configRepository};
timer.discoveryTicks()
.collect([](const auto& scheduledAt) {
discoverDevices(scheduledAt);
});
timer.thingTicks()
.collect([](const auto& scheduledAt) {
pollThings(scheduledAt);
});The one-worker HTTP server runs its libhv event loop on the main thread and
registers it through src/common/Reactor.hpp. Timers and network discovery use
that same loop, so HTTP, UDP, TCP, and timer callbacks execute sequentially.
No application component creates worker threads or performs blocking network
waits.
common::Persistence stores typed properties as TOML in
/var/lib/iotic/iotic.conf using reflected PropertyKey names:
neubau::common::Persistence persistence;
persistence.save<neubau::common::PropertyKey::discoveryInterval>(
neubau::common::Seconds{60});
const auto interval =
persistence.restore<
neubau::common::PropertyKey::discoveryInterval>();At startup, the application searches _shelly._tcp and _http._tcp.
Shelly advertisements and go-eChargers identified by their DNS-SD TXT metadata
are logged with plog as they arrive. Completed Shelly discovery candidates are
also added to the runtime thing repository.
The web server exposes a read-only API on the same listener as the web UI:
GET http://127.0.0.1:8030/api/things
GET http://127.0.0.1:8030/api/things/{id}
GET /api/things returns status 200 and this JSON schema, where every item
is a current repository entry:
[
{
"id": "<stable thing id>",
"name": "<resolved display name>"
}
]GET /api/things/{id} URL-decodes id, returns status 200 for a known
thing, and uses this JSON schema. properties includes only present
properties; duration values such as thingInterval are integer seconds.
{
"id": "<stable thing id>",
"name": "<resolved display name>",
"properties": {
"thingInterval": 5
}
}An unknown ID returns status 404 with:
{
"error": "thing not found"
}Names are loaded when things enter the repository. To override a thing's
display name in /var/lib/iotic/iotic.conf, use its stable ID in a quoted TOML
key:
[things."<id>"]
name = "Kitchen relay"common::PortScanner checks configured address and port pairs concurrently
using libhv and emits each reachable endpoint:
neubau::common::PortScanner scanner{{
.addresses = {"192.168.1.10"},
.ports = {80, 443, 502},
}};
scanner.candidates().collect([](const neubau::common::OpenPort& candidate) {
use(candidate.address, candidate.port);
});
scanner.start();src/modbus/ModbusDiscovery.hpp scans only explicitly configured IPv4 CIDRs and
unit IDs. It uses bounded concurrency and read-only Modbus MEI 43/14
requests, with a read-only holding-register probe for devices that do not
implement Device Identification:
neubau::modbus::ModbusDiscovery discovery{{
.cidrs = {"192.168.1.0/24"},
.unitIds = {1, 2, 3},
}};
discovery.candidates().collect([](const neubau::modbus::ModbusThing& thing) {
use(thing.address, thing.unitId, thing.vendorName);
});
discovery.start();CIDR expansion is capped at 4096 hosts by default. Connection and response timeouts, concurrency, port, unit IDs, and the host cap are configurable.
src/sunspec/SunspecDiscovery.hpp discovers devices from explicitly configured
IPv4 CIDRs. For every reachable Modbus TCP endpoint, it uses one persistent
connection while that session remains viable and probes unit IDs from 1
through 247 sequentially in vendor-prioritized order. It stops unit probing
after the first fully valid, terminated SunSpec chain; when no header is
accepted, exhaustion reaches all 247 IDs. A valid header whose Common Model or
model chain cannot be completed also ends that endpoint scan without an
emission. The prefix is
1, 240, 126-127, 100, 2, 247, 241, 128-129, 3-4, 5, 242-244, 130-135, 6-10;
it then probes the ranges 11-99, 101-125, 136-239, and finally
245-246.
Each probe reads the four-register common header at address 40000 and accepts
only {0x5375, 0x6e53, 1, 65-or-66}. A valid header leads to Common Model 1
metadata and traversal of the complete model chain, which must end with the
0xffff terminator. Repeated model IDs are retained as separate ordered
locations, and unknown model IDs remain visible as locations even without a
parser. During unit-header probing, a transport closure replaces the closed
connection with a new session for that endpoint and continues at the next
untried priority ID. Once a valid header selects a unit, any closure or other
failure while reading Common Model data or traversing/chunking the model chain
completes that host scan without a candidate; it neither replaces the session
nor restarts unit probing. A completed candidate has a stable ID:
normalize(manufacturer) + "_" + normalize(product) + "_" + normalize(serial)
Normalization lowercases ASCII letters and replaces each non-[a-z0-9]
character with _; it neither trims nor collapses underscores.
At application startup, Neubau obtains the host's primary IPv4 /24 with
ModbusDiscovery::primaryIpv4Cidr(24). When available, it starts SunSpec
discovery on that CIDR and publishes completed things through the runtime Thing
API. If no primary IPv4 CIDR is available, it logs that SunSpec startup was
skipped. SunSpec discovery only identifies devices and model locations; it does
not poll or expose SunSpec telemetry properties.
For an explicitly configured integration:
neubau::sunspec::SunspecDiscoveryOptions options;
options.modbus.cidrs = {"192.168.1.0/24"};
neubau::sunspec::SunspecDiscovery discovery{std::move(options)};
discovery.candidates().collect(
[](const neubau::sunspec::SunspecThing& thing) {
use(thing.manufacturer, thing.model, thing.modelLocations);
});
discovery.start();cmake -S . -B build
cmake --build build
./build/src/neubau