Compare commits

...
6 Commits
Author SHA1 Message Date
root 697b68ec39 Merge PR #66: feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m51s
2026-10-24 11:29:22 +02:00
root 645bbc4b88 Merge PR #65: docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed 2026-10-24 11:29:22 +02:00
rootandClaude Opus 5.5 2d7899ebc8 feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:26:55 +02:00
rootandClaude Opus 5.5 c34a9da0d7 docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:28 +02:00
root a8f6384e04 Merge PR #64: fix(modbus): close an idle connection, so no poll meets a reset socket
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Release / Images (push) Successful in 2m53s
2026-10-24 11:13:20 +02:00
rootandClaude Opus 5.5 3c68dc3621 fix(modbus): close an idle connection, so no poll meets a reset socket
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
On the Pi every other heat pump poll failed with 'Connection reset by
peer': MK01 drops a connection left idle for minutes, and the gateway
kept one open between polls (300 s). The socket now closes after 5 idle
seconds and the next poll connects anew, which also frees the single
MK01 connection for the vendor's app between polls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:11:09 +02:00
6 changed files with 72 additions and 13 deletions

No files matched your search

+6 -8
View File
@@ -27,9 +27,10 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
baseline classes (D4, D5)
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
`rpi-measurements` and the old `home-pump.service`
- [x] On the Pi (2026-10-24): D7, Docker (no iptables, no forwarding), `gateway.json`, ufw 8080
on `wg0` only, `v1.1.1` running with the heat pump, Deye and Fronius; old service stopped
- [ ] IIO overlay and reboot, then BME280; stop `rpi-measurements` before the gateway polls the
meter, then retire it and the old `home-pump.service` files
## Next
@@ -51,11 +52,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
- D7 fixed 2026-10-24: Pi `AllowedIPs` only `10.0.0.0/24, 10.2.0.0/24`, and OPNsense NATs
`192.168.0.0/24 → 10.0.0.2` to its tunnel address, as both sites use `192.168.0.0/24`.
## Knowledge
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#0e2a21">
<title>Dom Krosno</title>
<title>Dom Szczepańcowa</title>
<link rel="icon" href="data:,">
</head>
<body>
+2 -2
View File
@@ -60,7 +60,7 @@ function SideFooter() {
<Icon name="sun" className="size-9 text-amber-400" />
<div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Krosno</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
</div>
</div>
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
@@ -97,7 +97,7 @@ export function App() {
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
<div>
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
</div>
</div>
@@ -7,11 +7,16 @@ import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
/**
* Modbus TCP (MBAP) over one long-lived socket. The MK01 gateway serves a single client and a
* second connection drops the first, so the socket is reused and requests never overlap.
* Modbus TCP (MBAP). The MK01 gateway serves a single client and a second connection drops the
* first, so requests never overlap and the socket is reused for the requests of one poll. It is
* closed after a few idle seconds: MK01 resets a connection left idle for minutes, which cost every
* other poll, and while one is open the vendor's app cannot connect.
*/
public final class TcpTransport implements ModbusTransport {
@@ -20,17 +25,26 @@ public final class TcpTransport implements ModbusTransport {
}
private static final int HEADER_LENGTH = 7;
static final Duration IDLE_CLOSE = Duration.ofSeconds(5);
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
private final ReentrantLock lock = new ReentrantLock();
private final Settings settings;
private final Duration idleClose;
private final ScheduledExecutorService idleCloser = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("modbus-idle-close").factory());
private Socket socket;
private DataInputStream in;
private int transactionId;
private long lastResponseNanos;
public TcpTransport(Settings settings) {
this(settings, IDLE_CLOSE);
}
TcpTransport(Settings settings, Duration idleClose) {
this.settings = settings;
this.idleClose = idleClose;
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
}
@@ -52,6 +66,9 @@ public final class TcpTransport implements ModbusTransport {
socket.getOutputStream().write(frame(id, unitId, requestPdu));
byte[] response = readResponse(id, unitId);
lastResponseNanos = System.nanoTime();
if (!idleCloser.isShutdown()) {
idleCloser.schedule(this::closeIfIdle, idleClose.toNanos(), TimeUnit.NANOSECONDS);
}
return response;
} catch (IOException e) {
closeSocket();
@@ -63,12 +80,25 @@ public final class TcpTransport implements ModbusTransport {
public void close() {
lock.lock();
try {
idleCloser.shutdownNow();
closeSocket();
} finally {
lock.unlock();
}
}
/** A later request in the same poll moved lastResponseNanos on, and the socket stays. */
private void closeIfIdle() {
lock.lock();
try {
if (System.nanoTime() - lastResponseNanos >= idleClose.toNanos()) {
closeSocket();
}
} finally {
lock.unlock();
}
}
private void awaitPause() throws InterruptedIOException {
long remaining = settings.minimumPause().toNanos() - (System.nanoTime() - lastResponseNanos);
if (remaining <= 0) {
@@ -21,6 +21,7 @@ public final class MockModbusServer implements Closeable {
private final Map<Integer, Integer> holding = new ConcurrentHashMap<>();
private final Map<Integer, Integer> input = new ConcurrentHashMap<>();
private final AtomicInteger connections = new AtomicInteger();
private final AtomicInteger open = new AtomicInteger();
private final List<int[]> writes = new CopyOnWriteArrayList<>();
private volatile Duration responseDelay = Duration.ZERO;
private volatile boolean dropNextRequest;
@@ -42,6 +43,11 @@ public final class MockModbusServer implements Closeable {
return connections.get();
}
/** Connections the client has not closed yet. */
public int openConnections() {
return open.get();
}
public void holding(int address, int value) {
holding.put(address, value);
}
@@ -94,6 +100,7 @@ public final class MockModbusServer implements Closeable {
try {
Socket client = server.accept();
connections.incrementAndGet();
open.incrementAndGet();
Thread.ofVirtual().start(() -> serve(client));
} catch (IOException e) {
return;
@@ -128,6 +135,8 @@ public final class MockModbusServer implements Closeable {
}
} catch (IOException ignored) {
// the client went away, which is a normal end of a test
} finally {
open.decrementAndGet();
}
}
@@ -61,6 +61,28 @@ class TcpTransportTest {
assertEquals(1, server.connections());
}
@Test
void closesTheConnectionWhenIdleAndReconnectsForTheNextPoll() throws Exception {
transport.close();
transport = new TcpTransport(new TcpTransport.Settings(
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO),
Duration.ofMillis(100));
transport.exchange(1, READ_HOLDING_10);
transport.exchange(1, READ_HOLDING_10);
assertEquals(1, server.openConnections());
// Idle: the controller is free for the vendor's app, and no socket goes stale.
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
while (server.openConnections() > 0 && System.nanoTime() < deadline) {
Thread.sleep(20);
}
assertEquals(0, server.openConnections());
assertArrayEquals(HOLDING_10_RESPONSE, transport.exchange(1, READ_HOLDING_10));
assertEquals(2, server.connections());
}
@Test
void reconnectsAfterServerDropsConnection() throws IOException {
transport.exchange(1, READ_HOLDING_10);