diff --git a/.gitea/ACTIONS-README.md b/.gitea/ACTIONS-README.md new file mode 100644 index 0000000..9dd6a84 --- /dev/null +++ b/.gitea/ACTIONS-README.md @@ -0,0 +1,141 @@ +# Gitea Actions: quick notes 🔧 + +## What this adds +- `.gitea/workflows/ci.yml` — runs backend Maven tests and builds the frontend on every `push`. + +## Requirements (quick checklist) +1. **Enable Actions** for this repository: Repository Settings → Actions → Enable Repository Actions. +2. **Runner (act_runner)**: you must register at least one runner so jobs can execute. + - Prefer running the runner on a separate machine from the Gitea server. + - You can use the `gitea/act_runner` docker image or the binaries from Gitea downloads. + +### Example runner registration (local machine) +1. On your host, download or pull the runner image: `docker pull gitea/act_runner:latest`. +2. Start a container for registration only (refer to docs for token and instance): + - Get a token from Gitea UI: **Settings** → **Actions** → **Runners** → **Generate token** (repo/org/instance level). + - Run the register command (example using container; adjust instance and token): + ```bash + docker run --rm -it -v $PWD:/data gitea/act_runner register --instance https://your.gitea.host --token + ``` +3. Start the runner daemon (on the machine where you registered): + ```bash + ./act_runner daemon + # or use the docker image as a long-lived container after registration + ``` + +## Secrets & env +- If tests or builds need credentials, add them to **Settings → Secrets** at repo/org/instance level and reference via `${{ secrets.MY_SECRET }}`. + +## Notes & tips +- The workflow uses `actions/setup-java` and `actions/setup-node` which fetch from public actions. If your runner has restricted network access, adjust steps to install local toolchains instead. +- If you want to run integration tests or deploy artifacts, tell me which target host or container you'll use and I can add deploy steps. + +--- + +## Deploying the runner to all workers (recommended for docker-executor) +If you want the runner to run on every worker node, use the provided `gitea-act-runner-stack.yml` which runs the service in `mode: global` with a placement constraint for workers: + +```yaml +deploy: + mode: global + placement: + constraints: + - node.role == worker +``` + +Notes: +- This will start one runner instance on each worker node. If you need fewer runners, switch to `mode: replicated` and use `replicas` with a node label constraint (e.g., `node.labels.runner == true`). +- Mounting `/var/run/docker.sock` gives the runner the ability to run job containers (docker-executor) but is a security-sensitive choice — only run this on trusted worker nodes. + +### Quick deploy +```bash +# from a manager node +docker stack deploy -c gitea-act-runner-stack.yml act-runner +``` + +### Optional: label nodes instead of using node.role +If you prefer to run on a subset of workers, add a label to desired nodes and update the `placement.constraints`: + +```bash +docker node update --label-add runner=true +``` + +Then use: + +```yaml +placement: + constraints: + - node.labels.runner == true +``` + +--- + +## Registration examples & troubleshooting 🔍 +If you see errors like `instance address is empty`, it means the runner registration command wasn't given an instance URL (via `--instance` or `GITEA_INSTANCE_URL`). Use one of these approaches depending on your environment. + +1) One-shot CLI (internal Swarm network) +```bash +docker run --rm -it --network gitea -v act_runner-data:/data gitea/act_runner register \ + --instance http://gitea_server:3000 --token --no-interactive --name swarm-runner +``` +Use the internal service name (`gitea_server:3000`) when registering from inside the Swarm `gitea` network. + +2) One-shot (public/Traefik URL) +```bash +docker run --rm -it -v act_runner-data:/data gitea/act_runner register \ + --instance https://git.mariuszpacek.pl --token --no-interactive --name swarm-runner +``` +If you use an HTTPS URL, ensure the container trusts the cert (or use the internal HTTP address if available). + +3) Auto-register & run with env vars (recommended for simple Docker usage) +This will register (if needed) and start the runner in one step: +```bash +docker run -d --name gitea-runner \ + -v act_runner-data:/data -v /var/run/docker.sock:/var/run/docker.sock \ + -e GITEA_INSTANCE_URL=https://git.mariuszpacek.pl \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + -e GITEA_RUNNER_NAME=swarm-runner \ + gitea/act_runner:latest +``` + +4) Portainer notes +- For a temporary registration container, set image `gitea/act_runner:latest`, **Command** to: + `register --instance http://gitea_server:3000 --token --no-interactive` +- Or set environment variables `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN` and start the container (detached) to auto-register. + +5) Debugging checks +- Test reachability from the registrator container: + ```bash + docker run --rm --network gitea curlimages/curl curl -sS http://gitea_server:3000/api/v1/version + ``` +- Verify `.runner` written to the volume: + ```bash + docker run --rm -v act_runner-data:/data alpine sh -c "ls -la /data && cat /data/.runner" + ``` +- Check logs of the register container / service for precise errors. + +If the error persists, paste the exact register command you ran and its container logs and I'll inspect them. + +--- + +## Portainer: secure register + deploy (recommended) +Use a one-shot *registration stack* (deploy it, wait for logs, then remove it) and then deploy the long-running daemon stack that uses a Docker secret for the registration token. + +1) **Create a secret** in Portainer (or via CLI): + - Portainer: *Secrets → Add secret* → name: `registration_token` → value: `` + - CLI: `echo "" | docker secret create registration_token -` + +2) **Register (one-shot)** — Stack: `.gitea/act-runner-register-stack.yml` + - Deploy in Portainer → set stack variables: + - `INSTANCE_URL` = `http://gitea_server:3000` (or `https://git.mariuszpacek.pl`) + - `REGISTRATION_TOKEN` = *your token* (or use a temporary secret and modify command to read it) + - Check service logs for success, then remove this stack. + +3) **Deploy daemon (global on workers)** — Stack: `.gitea/act-runner-deploy-stack.yml` + - Ensure secret `registration_token` exists (see step 1). + - Deploy stack and set `INSTANCE_URL` and `RUNNER_NAME` as environment variables in Portainer. + - Runner will start as a global service on workers and use `/run/secrets/registration_token` (no token stored in repo). + +Notes: +- **Do not commit tokens to the repo.** Use Docker secrets or Portainer's secret store. +- If you prefer a quick test, the register stack accepts `REGISTRATION_TOKEN` as an env var (less secure). After successful registration, remove the env token and rely on the Docker secret for the daemon stack. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..260fb66 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: ["**"] + +jobs: + backend: + name: Backend tests (Maven) + runs-on: ['self-hosted','swarm-runner'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show runner info + run: | + echo "Runner name: ${{ runner.name }}" + echo "Runner labels: ${{ toJSON(runner.labels) }}" + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Cache Maven + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Run Maven tests + run: mvn -B test + + frontend: + name: Frontend build (node) + runs-on: ['self-hosted','swarm-runner'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show runner info + run: | + echo "Runner name: ${{ runner.name }}" + echo "Runner labels: ${{ toJSON(runner.labels) }}" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Cache node modules + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install & Build + run: | + cd frontend + npm ci + npm run build diff --git a/src/test/java/home/automation/modbus/ModbusParserTests.java b/src/test/java/home/automation/modbus/ModbusParserTests.java new file mode 100644 index 0000000..870d0c7 --- /dev/null +++ b/src/test/java/home/automation/modbus/ModbusParserTests.java @@ -0,0 +1,89 @@ +package home.automation.modbus; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +class ModbusParserTests { + + private ModbusRtuTcpRepository createRepo() { + return new ModbusRtuTcpRepository(new Connection() { + @Override + public com.serotonin.modbus4j.ModbusMaster getConnection() { + throw new UnsupportedOperationException("not used in parser tests"); + } + }, java.util.Collections.emptyList()); + } + + private String invokeDecode(ModbusRtuTcpRepository repo, short[] data, String dataType, Double scale, Boolean legacy) throws Exception { + Method m = ModbusRtuTcpRepository.class.getDeclaredMethod("decodeValueFromShorts", short[].class, String.class, Double.class, Boolean.class); + m.setAccessible(true); + return (String) m.invoke(repo, (Object) data, dataType, scale, legacy); + } + + @Test + void decodeFourByteFloat() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + float value = 12.34f; + int bits = Float.floatToIntBits(value); + short high = (short) ((bits >>> 16) & 0xFFFF); + short low = (short) (bits & 0xFFFF); + String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_FLOAT", 1.0, null); + assertEquals("12.34", decoded); + } + + @Test + void decodeFourByteFloatSwapped() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + float value = -5.5f; + int bits = Float.floatToIntBits(value); + short high = (short) ((bits >>> 16) & 0xFFFF); + short low = (short) (bits & 0xFFFF); + // swapped: pass low first, then high and use SWAPPED dataType + String decoded = invokeDecode(repo, new short[]{low, high}, "FOUR_BYTE_FLOAT_SWAPPED", 1.0, null); + // rounding to 2 decimals expected by implementation + assertEquals("-5.5", decoded); + } + + @Test + void decodeFourByteUnsignedInt() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + long unsignedVal = 3000000000L; // > 2^31 + int bits = (int) unsignedVal; // low 32 bits + short high = (short) ((bits >>> 16) & 0xFFFF); + short low = (short) (bits & 0xFFFF); + String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_UNSIGNED", 1.0, null); + assertEquals((double) unsignedVal, Double.parseDouble(decoded), 0.0001); + } + + @Test + void decodeFourByteSignedInt() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + int signedVal = -12345678; + int bits = signedVal; + short high = (short) ((bits >>> 16) & 0xFFFF); + short low = (short) (bits & 0xFFFF); + String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_SIGNED", 1.0, null); + assertEquals((double) signedVal, Double.parseDouble(decoded), 0.0001); + } + + @Test + void decodeTwoByteSignedLegacyWrap() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + short s = (short) (-32760); // near legacy wrap sentinel + String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 1.0, true); + // calculation: -(s + 32768) * scale -> -( -32760 + 32768) = -8 + assertEquals("-8.0", decoded); + } + + @Test + void decodeTwoByteNormal() throws Exception { + ModbusRtuTcpRepository repo = createRepo(); + short s = (short) (123); + String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 0.1, false); + // 123 * 0.1 = 12.3 + assertEquals("12.3", decoded); + } +}