# SyncTide Operations Runbook

**Audience.** You're an operator or admin running a SyncTide install. Things
are working today; some day they won't be. This is the document you read at
3 AM when polling stopped or alarms went silent. It assumes the install is
already done — see `README_LOCAL_INSTALL.md` for that.

**Out of scope.** Day-1 install, source-code development (`DEVELOPMENT.md`),
backup/restore mechanics (`RECOVERY.md`). This file is the *operational*
companion to those.

---

## 1 · The 30-second triage

When something looks wrong, walk this list top-to-bottom. Stop at the first
thing that's red.

```powershell
# 1. Are the three services even running?
Get-Service SyncTideBackend, SyncTideIngestion, SyncTideUI

# 2. Is the backend itself responsive?
Invoke-RestMethod http://localhost:8000/version
# Expected: { version, schema_version, build_date }

# 3. Are the protocol pollers running (if you use them)?
Get-Service "SyncTide*Poller" -ErrorAction SilentlyContinue
# (modbus, opcua, iec104 — only the ones you've configured)

# 4. Quick health check on the database
$env:PGPASSWORD = "<your db password>"
& "C:\Program Files\PostgreSQL\16\bin\psql.exe" -U synctide -d synctide -h localhost -c `
  "SELECT pg_is_in_recovery(), now(), (SELECT MAX(time_stamp) FROM measurements);"
# Expected: not in recovery, now() current, MAX(time_stamp) within your
# poll interval × 2.
```

**If all four are green, the platform is up.** The problem is either a
specific device (skip to §6) or the user's expectation of what should be
happening (skip to §8).

**If any are red:**

| Symptom | First place to look |
|---|---|
| Service not running | `services.msc` → start it manually; if it crashes immediately, §3 (logs) |
| `/version` 500s or hangs | `logs/backend.log` — usually a DB connection issue or a startup migration that failed |
| Pollers all stopped | `logs/<protocol>_poller.log`; check the licence with `/license/status` |
| `pg_is_in_recovery() = t` | DB is replicating — out of scope for this doc, contact whoever set up replication |
| `MAX(time_stamp)` is hours old but services running | §6 (per-device diagnosis) |

---

## 2 · Service management

The Windows services are managed by NSSM. They auto-start on boot, restart
on crash (with exponential back-off), and write rotating logs.

### Start / stop / restart

```powershell
# Stop one
Stop-Service SyncTideBackend -Force

# Stop all SyncTide services in the right order (UI -> Ingestion -> Backend)
Stop-Service SyncTideUI, SyncTideIngestion, SyncTideBackend -Force

# Start them back (Backend -> Ingestion -> UI is the right order)
Start-Service SyncTideBackend
Start-Sleep 3   # let the backend's migrations complete + listen on 8000
Start-Service SyncTideIngestion, SyncTideUI

# Restart everything (rough but quick)
Restart-Service SyncTideBackend, SyncTideIngestion, SyncTideUI -Force
```

### When a service won't start

`Start-Service SyncTideBackend` returns `Service cannot be started` →
this is almost always a startup error. NSSM logged it:

```powershell
Get-Content "C:\Program Files\SyncTide\logs\backend.err.log" -Tail 50
Get-Content "C:\Program Files\SyncTide\logs\backend.out.log" -Tail 50
```

Top three causes:

1. **Stale `.pyd` files after manual file replacement** (e.g. mid-update
   where files were copied but `integrity_manifest.json` wasn't refreshed).
   Reinstall via `SyncTideSetup-X.Y.Z.exe` — it'll repair file integrity.
2. **DB connection broken.** `.env` has wrong credentials, or PostgreSQL
   isn't running. `Get-Service postgresql-x64-16` should be `Running`.
3. **Migration failure on startup.** Backend runs `init_db.run_migrations()`
   on every start. If a migration errors out, the backend refuses to
   serve. See `logs/migrations.log` for the actual SQL error.

---

## 3 · Logs — what each one contains

Every log is in `C:\Program Files\SyncTide\logs\`. All are rotating files
capped at 5 MB × 5 backups (so ~25 MB max retention per log).

| File | What's in it | When to read it |
|---|---|---|
| `backend.log` | FastAPI app log: every API call, errors, license checks | Anything API-side, alarm rule errors, virtual-tag eval errors |
| `backend.out.log` / `.err.log` | NSSM's stdout/stderr capture — startup messages | Service refuses to start |
| `messaging.log` | Escalation engine, Twilio/Telegram/SMTP send results | Alarm fires but no message goes out |
| `ingest_csvs.log` | CSV ingestion file-by-file audit | CSV not appearing in Dashboard |
| `modbus_tcp_poller.log` | Per-poll-cycle outcome per device | Modbus device intermittently failing |
| `opcua_poller.log` | OPC UA polling + subscription events | OPC UA tag values stale or missing |
| `iec104_poller.log` | c104 client connection + ASDU dispatch | IEC 104 RTU not delivering |
| `migrations.log` | Schema migration runner output | Service refuses to start after upgrade |
| `update.log` | Auto-updater (file replace + rollback) | An update went sideways |
| `supervisor.log` | Process-supervisor (only if you use the supervisor pattern) | Pollers crashing in a loop |

### Useful one-liners

```powershell
# Tail the backend log live
Get-Content "C:\Program Files\SyncTide\logs\backend.log" -Wait -Tail 20

# Find the last error across all logs
Get-ChildItem "C:\Program Files\SyncTide\logs\*.log" |
  ForEach-Object { Select-String $_.FullName -Pattern "ERROR|Traceback" |
  Select-Object -Last 1 }

# How many failed polls in the last hour for a given device?
Select-String "C:\Program Files\SyncTide\logs\modbus_tcp_poller.log" `
  -Pattern "device=PLC_Bancada.*driver error" |
  Where-Object { (Get-Item $_.Path).LastWriteTime -gt (Get-Date).AddHours(-1) } |
  Measure-Object | Select-Object -ExpandProperty Count
```

---

## 4 · Database diagnostics

### Quick health snapshot

```sql
-- Run via psql or pgAdmin against the synctide database

-- Are timestamps current?
SELECT MAX(time_stamp) AS latest, NOW() - MAX(time_stamp) AS lag
FROM measurements;
-- lag should be < 2× any device's poll_interval_seconds

-- Per-device polling health (last 20 polls)
SELECT
  device_code,
  protocol_type,
  polling_enabled,
  last_poll_status,
  last_poll_at,
  poll_interval_seconds,
  jsonb_array_length(recent_poll_results) AS history_len,
  (SELECT COUNT(*) FROM jsonb_array_elements(recent_poll_results) e
   WHERE (e->>'ok')::boolean) AS ok_count
FROM devices
WHERE protocol_type <> 'csv'
ORDER BY device_code;

-- How many measurements stored today?
SELECT COUNT(*) FROM measurements WHERE time_stamp >= CURRENT_DATE;

-- Unprocessed alarm events
SELECT COUNT(*) FROM alarm_events WHERE acknowledged_at IS NULL AND cleared_at IS NULL;

-- Schema version
SELECT MAX(version) FROM schema_migrations;
```

### Slow queries

If the UI feels sluggish, find slow queries:

```sql
SELECT pid, state, EXTRACT(EPOCH FROM (NOW() - query_start))::int AS seconds, LEFT(query, 200)
FROM pg_stat_activity
WHERE state = 'active' AND pid <> pg_backend_pid()
ORDER BY query_start;
```

Anything > 5 s on a small dataset (< 1 M measurement rows) is suspicious.

### Database getting full

`measurements` is the only table that grows unbounded. Inspect it:

```sql
SELECT
  pg_size_pretty(pg_total_relation_size('measurements')) AS measurements_size,
  (SELECT COUNT(*) FROM measurements) AS row_count;
```

Retention policy is operator-driven (no automatic pruning ships with
SyncTide). To trim, e.g. drop older than 90 days:

```sql
BEGIN;
DELETE FROM measurements WHERE time_stamp < NOW() - INTERVAL '90 days';
-- review row count BEFORE committing
COMMIT;
VACUUM ANALYZE measurements;
```

---

## 5 · Update / rollback

Updates ship as either:

- **Full installer**: `SyncTideSetup-X.Y.Z.exe` (~1 GB). In-place upgrade
  via the same `AppId`; auto-detects existing install and skips wizard
  pages.
- **Update package**: `SyncTide-A.B.C-to-X.Y.Z.synctide-update` (~3 MB
  signed delta). Double-click → `update_ui.py` GUI walks through it.

### Before any upgrade

**Always back up the database first.**

```powershell
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
& "C:\Program Files\PostgreSQL\16\bin\pg_dump.exe" `
  -U postgres -F c `
  -f "C:\synctide_pre_upgrade_$stamp.dump" synctide
```

### If an upgrade fails midway

Updates are designed to roll back automatically on any error during file
replacement. If the auto-rollback completed cleanly, the platform should
already be running the previous version — verify with
`Invoke-RestMethod http://localhost:8000/version`.

If auto-rollback didn't trigger or didn't fully recover:

```powershell
# 1. Stop services
Stop-Service SyncTideUI, SyncTideIngestion, SyncTideBackend -Force

# 2. Restore from the backup taken before the upgrade
$env:PGPASSWORD = "<postgres superuser password>"
& "C:\Program Files\PostgreSQL\16\bin\dropdb.exe" -U postgres synctide
& "C:\Program Files\PostgreSQL\16\bin\createdb.exe" -U postgres synctide
& "C:\Program Files\PostgreSQL\16\bin\pg_restore.exe" `
  -U postgres -d synctide "C:\synctide_pre_upgrade_<stamp>.dump"

# 3. Reinstall the previous version's installer over the broken one
&"C:\path\to\SyncTideSetup-<previous-version>.exe"

# 4. Start services
Start-Service SyncTideBackend, SyncTideIngestion, SyncTideUI
```

`RECOVERY.md` has the full disaster-recovery walkthrough; this is the
common-case condensed version.

### Migration 026 caveat (any v1.10.x upgrade)

If the source DB has historical naive-timestamp duplicates that collide
once columns become `TIMESTAMPTZ` (notably any test data generated across
DST boundaries), migration 026 will fail with:

```
could not create unique index "uq_measurements_device_time_tag"
```

The backend won't start. Recovery:

```sql
DELETE FROM measurements m USING (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (
      PARTITION BY device_id, tag_name,
                   (time_stamp AT TIME ZONE current_setting('timezone'))
      ORDER BY id
    ) AS rn FROM measurements
  ) ranked WHERE rn > 1
) dup WHERE m.id = dup.id;
```

Then re-apply migration 026 manually (it's idempotent since v1.11.1) or
restart the backend (it'll resume the migration run).

---

## 6 · Per-device diagnosis

Steps once you've narrowed the problem to one device.

### "Polling status is red"

1. Open Equipment Configuration → select the device → Communication tab.
2. The status row tells you the rolling success rate (`✅ OK · 20/20`,
   `⚠ Intermittent · 8/15`, `❌ Mostly failing`).
3. Click the ⓘ popover for the last error message.
4. Open the corresponding poller log (`logs/<protocol>_poller.log`) and
   filter on `device=<your_code>`.

### Common error strings → cause

| Error string | Likely cause | Fix |
|---|---|---|
| `could not connect to <ip>:502` (Modbus, intermittent) | S7-1200 MB_SERVER cooldown | Toggle "Keep connection warm" in Endpoint Configuration |
| `Client is not connected` (Modbus, after first read) | pymodbus-asyncua interaction with sticky-cooldown PLCs | Same fix — keep-alive |
| `BadIdentityTokenInvalid` (OPC UA) | Server requires authentication | Set username/password on Endpoint Configuration |
| `BadCertificateUntrusted` (OPC UA) | Server requires certificate auth | Out of scope for v1.10 — use a server with anonymous + None policy, or wait for OPC UA security in a later release |
| `column "X" does not exist` (in `migrations.log`) | Schema drift on an old DB | See §5 migration 026 caveat or contact support |
| `connection refused 192.168.x.x:2404` (IEC 104) | RTU offline or wrong port | Ping the host; check IEC 104 port (2404 default) is open |
| `c104: callback signature` (IEC 104) | Library version mismatch | Reinstall the IEC 104 poller wheel set |

### "Test connection" returns success but I'm not seeing data

Check whether the device is enabled for polling:

```sql
SELECT device_code, polling_enabled, poll_interval_seconds, last_poll_status, last_poll_at
FROM devices WHERE device_code = '<code>';
```

If `polling_enabled = false`, flip it on via Equipment Configuration.

If `polling_enabled = true` but `last_poll_at` is stale, the poller process
is dead. Restart it.

If the poller is running, check that **tag rows exist for the device**:

```sql
SELECT COUNT(*) FROM device_tag_addresses WHERE device_id = (
  SELECT id FROM devices WHERE device_code = '<code>'
) AND enabled = TRUE;
```

A device with zero enabled tags will appear "ok" but produce no
measurements — by design.

### S7-1200 specific

If MB_SERVER's `STATUS` register reads `0x80C8`:

- It means "connection establishment timeout / resource limit." Often a
  transient state when between client sessions; not a real fault.
- If it persists with no client connected: check that `MB_HOLD_REG`
  points to a DB block with **"Optimized block access" disabled** (this
  is the most common Siemens config mistake).

---

## 7 · Messaging / alarm escalation

### Alarms fire but no message arrives

1. **Check the rule has at least one channel:**
   ```sql
   SELECT id, name, channel_type, enabled FROM messaging_alarm_rules
   WHERE enabled = TRUE;
   ```
2. **Check the gateway is configured + active:**
   ```sql
   SELECT id, gateway_type, is_active, last_error FROM messaging_gateways;
   ```
3. **Check `messaging_history` for the queued message:**
   ```sql
   SELECT id, alarm_event_id, channel_type, status, retry_count, last_error
   FROM messaging_history ORDER BY id DESC LIMIT 20;
   ```
   - `status = 'pending'` and `retry_count > 0`: backoff is working through
     transient errors, leave it.
   - `status = 'failed'`: maxed-out retries. Check `last_error`.
4. **Check `messaging.log`** for live send attempts.

### Specific provider issues

| Provider | Common error | Fix |
|---|---|---|
| Twilio (SMS / WhatsApp) | `21610` (unsubscribed) | Recipient texted STOP — they need to text START |
| Twilio | `21408` (permission denied) | Geo permissions in Twilio dashboard need that destination country enabled |
| Telegram | `Bot was blocked by user` | Ask the user to /start the bot again |
| Telegram | `chat not found` | Wrong `chat_id` configured for that contact |
| SMTP | `535 Authentication failed` | Wrong app password (Gmail/O365 require app-specific passwords, not the account password) |
| SMTP | `connect ECONNREFUSED` | Outbound SMTP port (587) blocked by firewall |
| Teltonika RUT241 | `EFKHTTP-001` | Webhook secret mismatch — regenerate via the Messaging Center page |

---

## 8 · "It looks broken but actually isn't"

False alarms operators raise in the first month:

| Observation | What's actually happening |
|---|---|
| "Real Time view shows old timestamps" | Likely report-by-exception is on for the device; values only update when they change. Check the heartbeat setting if you want a periodic "alive" stamp regardless. |
| "Modbus polling at 5 s shows 50% success" | S7-1200 cooldown — enable keep-alive heartbeat. |
| "Curve rule alarm fires for stable equipment" | Tolerance band too tight, or X/Y bound to wrong tags. Open the rule and check the historical scatter overlay. |
| "Dashboard shows steps instead of smooth lines" | Report-by-exception is on. The data is correct; the chart is honestly representing "value didn't change." |
| "Status shows Intermittent but no real impact" | Background failed polls (e.g. occasional cooldown) without operator-visible consequence. The success rate is what matters. |

---

## 9 · Backups (the short version)

`RECOVERY.md` has the full procedure. Minimum viable backup:

```powershell
# Daily, scheduled
$stamp = Get-Date -Format "yyyyMMdd"
& "C:\Program Files\PostgreSQL\16\bin\pg_dump.exe" `
  -U postgres -F c `
  -f "D:\backups\synctide_$stamp.dump" synctide
```

Compress old dumps + ship them off-site monthly. Test the restore flow on
a sandbox VM at least once a year — backups you've never tested are not
backups.

---

## 10 · When to escalate

Things that genuinely need vendor help, not local recovery:

- Database corruption (PostgreSQL itself reporting page errors)
- Licence file rejected with no clear cause (the licence subsystem is
  Ed25519-signed and shouldn't refuse a valid `.lic` unless the file is
  damaged or the system clock is years off)
- Repeatable crashes in `protocol_drivers/*` that survive a restart
- Auto-update fails AND auto-rollback fails

Open an issue on the SyncTide repository (private) with:
- Output of `Invoke-RestMethod http://localhost:8000/version`
- Output of `Get-Service "SyncTide*"`
- Last 100 lines of the relevant log file
- A description of what was working and what changed

---

## 11 · Cloud (Docker) deployment runbook

This section covers the **second supported deployment shape** alongside
the Windows `.exe` installer: SyncTide running as a Docker Compose stack
on a Linux VPS, for customers who can't or won't install locally.

Both deployments ship the same source code; the differences are all about
packaging, networking, and TLS.

### 11.1 — VPS sizing

| Customer scale | vCPU | RAM | Disk | Bandwidth |
|---|---|---|---|---|
| ≤500 tags, 1s polls, ≤5 protocol pollers | 2 | 4 GB | 80 GB | 1 TB/mo |
| ≤2500 tags, 1s polls | 4 | 8 GB | 200 GB | 2 TB/mo |
| Unlimited tier with full MQTT load | 8 | 16 GB | 500 GB | unlimited |

**Disk**: dominated by the `measurements` hypertable. A typical 100-tag
device polled at 1s grows ~30 GB/year before TimescaleDB compression
(7-day policy reduces by ~90%). Multiply across devices.

**Tested OS**: Ubuntu 22.04 / 24.04 LTS, Debian 12, Rocky Linux 9. Any
distro with Docker 24+ and docker-compose-plugin 2.20+ should work.

### 11.2 — One-time provisioning

```bash
# On a fresh VPS:
sudo apt update && sudo apt install -y docker.io docker-compose-plugin git curl
sudo usermod -aG docker "$USER"   # log out and back in

# Pull the platform
sudo mkdir -p /opt/synctide && sudo chown "$USER:$USER" /opt/synctide
git clone https://github.com/tigogggg/SyncTide.git /opt/synctide
cd /opt/synctide
git checkout cloud-experiment      # or v1.15.0 once tagged

# Pull the Docker image (or build locally with `docker compose build`)
docker pull ghcr.io/tigogggg/synctide:1.15.0      # once published
# OR
docker compose build

# Configure
cp .env.example.docker .env
# Edit .env: set DB_PASSWORD, INSTANCE_ID (uuidgen), SYNCTIDE_DOMAIN,
#            ACME_EMAIL, SYNCTIDE_ADMIN_PASSWORD
nano .env

# Drop the customer's license file BEFORE the first `docker compose up`.
# Docker's bind-mount semantics treat a missing source as a directory:
# if the file doesn't exist at compose-up time, Docker silently creates
# an empty *directory* called ``license.cloud.lic`` at this path. The
# backend then sees a non-file at /app/license.lic and the license check
# fail-softs with "license file unreadable". Easy to recover from
# (delete the dir, drop the .lic, `docker compose restart backend`) but
# easier to avoid.
cp /path/to/their-license.lic license.cloud.lic
```

### 11.3 — Mosquitto auth (production hardening)

Don't run with `allow_anonymous true` in production. Steps:

```bash
mkdir -p mosquitto/passwd mosquitto/certs
cp mosquitto.acl.template mosquitto/acl

# Bring up the broker first so we can use mosquitto_passwd inside it
docker compose up -d mosquitto

# Create the platform's MQTT user (the synctide service uses this)
docker compose exec mosquitto mosquitto_passwd -c /mosquitto/config/passwd synctide

# Add per-equipment users
docker compose exec mosquitto mosquitto_passwd /mosquitto/config/passwd factory1-line3-sensor7
# (repeat per device)

# Edit the ACL file to scope each user to their topic prefix
nano mosquitto/acl

# Apply the production config + restart
docker compose -f docker-compose.yml -f compose.override.production.yml up -d
```

In the SyncTide UI → Configurations → MQTT Brokers, set the platform user
(`synctide`) and password to match what was set in `mosquitto_passwd`.

### 11.4 — Real TLS for the broker (optional but expected)

Caddy already auto-provisions a Let's Encrypt cert for `SYNCTIDE_DOMAIN`.
Mosquitto's TLS listener (8883) needs the same cert. Easiest way:
share Caddy's data volume with mosquitto.

```bash
# Mosquitto reads its certs from /mosquitto/certs/
# Caddy writes them under /data/caddy/certificates/acme-v02.../
# You can either:
#   (a) symlink them into ./mosquitto/certs/, or
#   (b) bind-mount a host directory and have a small cron sync them
```

A worked example (recommended for a single-domain deployment) lives
in the repo at `docs/MQTT_TLS_SETUP.md` once written.

### 11.5 — DNS

| Record | Type | Value |
|---|---|---|
| `customer.synctide.com` | A | VPS public IP |
| `mqtt.customer.synctide.com` (optional) | A | Same VPS |

Wait for DNS propagation (usually <5 min), then bring everything up:

```bash
docker compose -f docker-compose.yml -f compose.override.production.yml up -d
docker compose ps
docker compose logs -f caddy   # watch Let's Encrypt provision the cert
```

First Caddy start takes 30-60s while it talks to Let's Encrypt. After
that the platform is reachable at `https://customer.synctide.com`.

### 11.6 — Backups (cloud)

```bash
chmod +x backup_cloud.sh
./backup_cloud.sh                 # one-shot — writes to ./backups/<UTC-ts>/
crontab -e
# Add: 0 3 * * * cd /opt/synctide && ./backup_cloud.sh >> /var/log/synctide-backup.log 2>&1
```

The script captures: `pg_dump` of the DB (custom format), tar of bind-mount
data dirs (raw_data / reports / templates), tar of named volumes
(mosquitto_data, caddy_data — for TLS certs), license file, and a
sanitised `.env`. Default retention is 30 days; older backups auto-prune.

Off-VPS replication (recommended): rsync the `backups/` directory to your
own object store nightly. Example with rclone + S3:

```bash
rclone sync /opt/synctide/backups backup-bucket:synctide-customer-name --transfers=4
```

### 11.7 — Updates

```bash
cd /opt/synctide
git fetch && git checkout v1.15.x   # whichever version you're rolling out
docker compose pull                  # if image is in a registry
# OR
docker compose build                 # if building locally
docker compose -f docker-compose.yml -f compose.override.production.yml up -d
docker compose logs -f backend       # watch migrations apply
```

Database migrations run automatically on backend startup. Roll-forward only —
to roll back, restore from backup.

### 11.8 — Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Can't reach `https://customer.synctide.com` | Let's Encrypt didn't provision yet | `docker compose logs caddy` — wait for "obtained certificate" line |
| MQTT subscriber restarts in a loop | No broker configured in UI | Configurations → MQTT Brokers → Add. Then `docker compose restart mqtt-subscriber`. |
| MQTT subscriber connects but no data | Broker auth failure or topic ACL mismatch | `docker compose logs mosquitto` — check for `Connection refused` or `Denied PUBLISH` |
| Equipment Configuration shows "License pending" | License file missing or `INSTANCE_ID` mismatch | Confirm `.env`'s `INSTANCE_ID` matches the `cloud_instance_id` in the .lic file |
| Backend logs `psql: error: connection ... password authentication failed` | DB password rotated but service env var not updated | `docker compose down && docker compose up -d` after fixing `.env` |
| Disk filling up rapidly | TimescaleDB compression policy not running | Configurations → Data Retention → check policy is enabled; `docker compose exec db psql -U synctide -c "SELECT * FROM timescaledb_information.compression_settings;"` |

### 11.9 — Comparison to the Windows `.exe` deployment

| | Windows `.exe` | Docker Compose |
|---|---|---|
| OS | Windows 10/11/Server | Any Linux with Docker |
| DB | Bundled PG17.6 + TimescaleDB | `timescale/timescaledb:2.26.4-pg17` image |
| Services | 8 NSSM Windows services | 8 Docker containers |
| Updates | `.synctide-update` deltas | `docker compose pull && up -d` |
| Backup | `backup_platform.ps1` | `backup_cloud.sh` |
| Reverse proxy | Caddy on `:80` (LAN) | Caddy on `:80`/`:443` with auto-TLS |
| Access | `http://<host>.local` | `https://customer.synctide.com` |
| MQTT | New `SyncTideMQTT` service | `mqtt-subscriber` container |
| Customer fit | On-prem, OT-managed PCs | Cloud, IT-managed VPS |

Keep both supported. Same source tree, same schema, same API.

---

## Appendix A — File locations cheat sheet

| What | Where |
|---|---|
| Install root | `C:\Program Files\SyncTide\` |
| Logs | `C:\Program Files\SyncTide\logs\` |
| Embedded Python | `C:\Program Files\SyncTide\python\` |
| Migrations source | `C:\Program Files\SyncTide\migrations\` |
| `.env` (DB creds) | `C:\Program Files\SyncTide\.env` (admin-only ACL since v1.8.x) |
| Licence file | `C:\Program Files\SyncTide\license.lic` |
| Integrity manifest | `C:\Program Files\SyncTide\integrity_manifest.json` |
| PostgreSQL data | `C:\Program Files\PostgreSQL\17\data\` |
| PostgreSQL `psql` | `C:\Program Files\PostgreSQL\17\bin\psql.exe` |
| pgAdmin 4 | Start menu → pgAdmin 4 |

## Appendix B — Common ports

| Service | Port |
|---|---|
| SyncTide web UI (React, via Caddy) | 80 |
| SyncTide backend (FastAPI, serves the UI at /ui) | 8000 |
| PostgreSQL | 5432 |
| Modbus TCP slaves | 502 |
| OPC UA servers | 4840 |
| IEC 60870-5-104 RTUs | 2404 |

## Appendix C — Glossary

- **AppId** — Inno Setup installation identifier; lets the installer
  detect "this is an upgrade, not a fresh install." Don't ever change it.
- **CP56Time2a** — IEC 60870-5-104 timestamp format (7-byte structure).
  Many RTUs send local time; SyncTide's per-device `device_timezone`
  setting interprets it correctly.
- **DataChangeFilter** — OPC UA monitored-item filter that controls when
  the server sends notifications. SyncTide sets `Trigger=StatusValue` for
  efficient change-only delivery.
- **MB_SERVER** — Siemens TIA Portal Modbus TCP server instruction.
  Default config has aggressive cooldowns that motivate the keep-alive
  heartbeat feature.
- **Report-by-exception** — Per-device toggle that skips DB writes for
  unchanged values. Optional heartbeat forces a periodic write.
- **schema_migrations** — Table that tracks which numbered migrations
  (`NNN_*.sql` files) have been applied. The runner is idempotent and
  skips already-applied migrations.
- **SourceTimestamp** — OPC UA per-value timestamp from the server-side
  sample. SyncTide preserves it through the ingest pipeline.
- **TIMESTAMPTZ** — PostgreSQL "timestamp with time zone." Every
  timestamp column in SyncTide v1.10.0+ is this type.
