diff --git a/.claude/skills/translate-page/SKILL.md b/.claude/skills/translate-page/SKILL.md
new file mode 100644
index 0000000..c5c965c
--- /dev/null
+++ b/.claude/skills/translate-page/SKILL.md
@@ -0,0 +1,157 @@
+---
+name: translate-page
+description: Translate a documentation page from docs/en/ into another language and stamp it. Use when asked to translate a page, add a language, or bring a stale translation up to date in this repo.
+---
+
+# Translate a documentation page
+
+Translation here is a repeatable operation, not an ad-hoc prompt. The rules below
+exist because each of them was broken once and cost real work.
+
+## Inputs
+
+- A page path under `docs/en/`, or a page reported by
+ `uv run python scripts/translation_status.py` as `missing` or `stale`.
+- A target language directory, e.g. `docs/fi/`.
+
+## Before translating
+
+1. **Read the glossary for the target language** —
+ `solutions/translation/finnish-glossary.md` for Finnish, and its equivalent
+ for other languages. It fixes terminology, unit formatting, address form and
+ what stays in English. Follow it exactly.
+2. If the page introduces a term the glossary does not cover, **add it to the
+ glossary** in the same change. Do not invent a one-off translation: the whole
+ point is that the same English term reads the same way on every page.
+3. If the page is `stale` rather than `missing`, read the English diff the
+ status report prints. Translate the change, not the whole page.
+
+## Translating
+
+The translation lives at the mirrored path — `docs/en/hardware/index.md`
+becomes `docs/fi/hardware/index.md`. Only markdown goes under the language
+directory; images stay with the English source and are shared.
+
+**Preserve structure exactly.** Same headings, list items, numbered steps,
+images, admonitions, table rows, footnotes and code fences, in the same order.
+
+**Never touch:**
+
+- Code fences and their contents, including comments inside them
+- Inline code: commands, file paths, hostnames, config keys
+- UI strings the reader will see on their own screen in English
+- Product, protocol and hardware names
+- Image filenames and paths
+
+**Always convert:** units to SI spacing and decimal comma (`0.9A` → `0,9 A`,
+`5.5 x 2.1 mm` → `5,5 × 2,1 mm`). This is not optional formatting; it is the
+correct way to write the value.
+
+**Two markdown traps** that neither `--strict` nor GitHub's preview catches —
+both are documented in `solutions/best-practices/`:
+
+- A blank line before the first item of a list
+- Four spaces, not three, for a sub-list under a numbered step
+
+**Never write an `en/` or `fi/` segment into a path inside a page.** The
+language comes from which directory the file lives in.
+
+## Anchors
+
+Anchors derive from heading text, so translating a heading changes its slug.
+Slugs strip diacritics and lowercase: `Mikä HALMET on?` → `mika-halmet-on`.
+
+Two distinct jobs:
+
+1. **Inside the page you are translating** — rewrite every `](#…)` to the
+ translated heading's slug.
+2. **In pages you are not touching** — a link like
+ `](./operation.md#status-led-indicators)` in an already-translated page keeps
+ working until `operation.md` is translated, and breaks the moment it is. This
+ is a delayed fault. After translating, run the anchor check across the whole
+ built site, not just your page.
+
+Do not guess slugs. Build, then read the real ids out of the generated HTML.
+
+## Stamping
+
+The stamp records the git blob hash of the English source the translation was
+written against. Write it with the helper, never by hand:
+
+```bash
+uv run python scripts/stamp_translation.py docs/fi/hardware/index.md
+```
+
+**Stamp only when you have actually translated.** A stamp updated without real
+translation work reports green and makes the staleness invisible — that is the
+one failure the status check cannot detect, and this skill is where the
+discipline lives. If you touched only the target language (fixing wording,
+fixing a typo), the English source did not change: leave the stamp alone.
+
+## Verifying
+
+All four, every time:
+
+```bash
+uv run mkdocs build --strict
+uv run python scripts/check_anchors.py site
+uv run python scripts/translation_status.py
+uv run python scripts/check_glossary.py fi
+uv run python scripts/check_typography.py fi
+```
+
+**Leave every anchor fragment in its English form while translating**, then map
+them all at once once the language is complete and the site has been built:
+
+```bash
+uv run python scripts/map_anchors.py site fi # report
+uv run python scripts/map_anchors.py site fi --apply # rewrite
+```
+
+The mapping is positional — the nth heading of the English page and the nth
+heading of the translation are the same heading — which is why the structure
+comparison below has to pass first. Matching on heading text cannot work once
+the text is in another language.
+
+**Measure the glossary, do not reread it.** Rereading your own pages confirms
+whatever they already say, so the terminology looks consistent right up until a
+reviewer finds the same connector under two names on adjacent pages. Every
+language so far shipped that mistake, and each time it landed on the last pages
+translated, once the glossary had stopped being opened. `check_glossary.py`
+reports terms the glossary prescribes and the pages never use — the signature of
+a rival word having quietly taken over.
+
+The same applies to whatever typography rules the glossary sets. Test them
+against the text: count the quotation marks and check they pair, count the
+spaces before `;:!?`, count the address form. A rule that was read looks
+followed.
+
+and a structure comparison against the source:
+
+```bash
+python3 - <<'PY'
+import re
+en = 'docs/en/hardware/index.md'; fi = 'docs/fi/hardware/index.md'
+def stats(p):
+ t = re.sub(r'^---\n.*?\n---\n', '', open(p, encoding='utf-8').read(), flags=re.S)
+ return {k: len(re.findall(v, t, re.M)) for k, v in {
+ 'headings': r'^#{1,6} ', 'bullets': r'^\s*[-*] ', 'numbered': r'^\s*\d+\. ',
+ 'images': r'!\[', 'admonitions': r'^!!! ', 'table rows': r'^\|',
+ 'fences': r'^```'}.items()}
+a, b = stats(en), stats(fi)
+print(a); print(b); print('match' if a == b else 'MISMATCH')
+PY
+```
+
+A mismatch means content was dropped or merged. Find it before committing.
+
+Finally, confirm no numeric value drifted: every number in the English text
+should appear in the translation, unless it was deliberately spelled out as a
+word. A wrong voltage or current in an installation guide is a safety problem,
+not a typo.
+
+## Committing
+
+One commit per logical group of pages. If pages cross-link each other, translate
+and commit them together — otherwise the intermediate commit has links pointing
+at headings that do not exist yet.
diff --git a/.github/workflows/translation-status.yml b/.github/workflows/translation-status.yml
new file mode 100644
index 0000000..e21dd3b
--- /dev/null
+++ b/.github/workflows/translation-status.yml
@@ -0,0 +1,95 @@
+name: Translation Status
+
+on:
+ pull_request:
+ paths:
+ - 'docs/**'
+ - 'mkdocs.yml'
+ - 'scripts/**'
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: translation-status-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ status:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Full history: the report resolves the stamped blob to show the
+ # English diff since a translation was written.
+ fetch-depth: 0
+
+ - uses: astral-sh/setup-uv@v5
+ - run: uv sync
+
+ - name: Report translation status
+ run: |
+ # tee, not plain redirection: a report only in the job summary is
+ # invisible in the logs, which is where you look when it misbehaves.
+ uv run python scripts/translation_status.py --format markdown --diff \
+ | tee report.md
+ cat report.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Comment on the pull request
+ if: github.event_name == 'pull_request'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR: ${{ github.event.number }}
+ run: |
+ # Only the English pages this PR actually touches. Which paths a PR
+ # touched is a fact, so a PR editing only translations says nothing.
+ pages=$(git diff --name-only \
+ "origin/${{ github.base_ref }}...HEAD" -- 'docs/en/**/*.md' \
+ | sed 's|^docs/en/||')
+ if [ -z "$pages" ]; then
+ echo "No English pages touched; nothing to report."
+ exit 0
+ fi
+
+ # shellcheck disable=SC2086
+ uv run python scripts/translation_status.py \
+ --format markdown --diff --only-pages $pages > comment.md
+ printf '\n\n' >> comment.md
+
+ existing=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" \
+ --jq 'map(select(.body | contains(""))) | .[0].id // empty')
+ if [ -n "$existing" ]; then
+ gh api "repos/${{ github.repository }}/issues/comments/$existing" \
+ -X PATCH -F body=@comment.md --silent
+ echo "Updated comment $existing"
+ else
+ gh api "repos/${{ github.repository }}/issues/$PR/comments" \
+ -F body=@comment.md --silent
+ echo "Created comment"
+ fi
+
+ # Last, because unlike a stale translation a broken anchor is actual
+ # breakage and fails the run — and the report above must still be
+ # published when it does.
+ - name: Check anchors
+ run: |
+ uv run mkdocs build --strict
+ # PIPESTATUS, not $?: piping into tee would otherwise mask the
+ # checker's exit status behind tee's.
+ set +e
+ uv run python scripts/check_anchors.py site | tee anchors.txt
+ broken=${PIPESTATUS[0]}
+ set -e
+ {
+ echo ""
+ echo "## Anchor check"
+ echo ""
+ echo '```'
+ cat anchors.txt
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ exit "$broken"
diff --git a/docs/errata/index.md b/docs/en/errata/index.md
similarity index 100%
rename from docs/errata/index.md
rename to docs/en/errata/index.md
diff --git a/docs/getting-started/halmet_n2k_input.jpg b/docs/en/getting-started/halmet_n2k_input.jpg
similarity index 100%
rename from docs/getting-started/halmet_n2k_input.jpg
rename to docs/en/getting-started/halmet_n2k_input.jpg
diff --git a/docs/getting-started/halmet_small_enclosure.jpg b/docs/en/getting-started/halmet_small_enclosure.jpg
similarity index 100%
rename from docs/getting-started/halmet_small_enclosure.jpg
rename to docs/en/getting-started/halmet_small_enclosure.jpg
diff --git a/docs/getting-started/index.md b/docs/en/getting-started/index.md
similarity index 100%
rename from docs/getting-started/index.md
rename to docs/en/getting-started/index.md
diff --git a/docs/getting-started/medium_enclosure.jpg b/docs/en/getting-started/medium_enclosure.jpg
similarity index 100%
rename from docs/getting-started/medium_enclosure.jpg
rename to docs/en/getting-started/medium_enclosure.jpg
diff --git a/docs/getting-started/n2k_connector.jpg b/docs/en/getting-started/n2k_connector.jpg
similarity index 100%
rename from docs/getting-started/n2k_connector.jpg
rename to docs/en/getting-started/n2k_connector.jpg
diff --git a/docs/getting-started/power_connector.jpg b/docs/en/getting-started/power_connector.jpg
similarity index 100%
rename from docs/getting-started/power_connector.jpg
rename to docs/en/getting-started/power_connector.jpg
diff --git a/docs/halmet_v1_top_photo.jpg b/docs/en/halmet_v1_top_photo.jpg
similarity index 100%
rename from docs/halmet_v1_top_photo.jpg
rename to docs/en/halmet_v1_top_photo.jpg
diff --git a/docs/hardware/HALMET-conx-bottom.jpg b/docs/en/hardware/HALMET-conx-bottom.jpg
similarity index 100%
rename from docs/hardware/HALMET-conx-bottom.jpg
rename to docs/en/hardware/HALMET-conx-bottom.jpg
diff --git a/docs/hardware/HALMET-conx-top.jpg b/docs/en/hardware/HALMET-conx-top.jpg
similarity index 100%
rename from docs/hardware/HALMET-conx-top.jpg
rename to docs/en/hardware/HALMET-conx-top.jpg
diff --git a/docs/hardware/HALMET-func.jpg b/docs/en/hardware/HALMET-func.jpg
similarity index 100%
rename from docs/hardware/HALMET-func.jpg
rename to docs/en/hardware/HALMET-func.jpg
diff --git a/docs/hardware/HALMET-isolation.jpg b/docs/en/hardware/HALMET-isolation.jpg
similarity index 100%
rename from docs/hardware/HALMET-isolation.jpg
rename to docs/en/hardware/HALMET-isolation.jpg
diff --git a/docs/hardware/HALMET-render-bottom.png b/docs/en/hardware/HALMET-render-bottom.png
similarity index 100%
rename from docs/hardware/HALMET-render-bottom.png
rename to docs/en/hardware/HALMET-render-bottom.png
diff --git a/docs/hardware/HALMET-render-se.png b/docs/en/hardware/HALMET-render-se.png
similarity index 100%
rename from docs/hardware/HALMET-render-se.png
rename to docs/en/hardware/HALMET-render-se.png
diff --git a/docs/hardware/HALMET-render-top-ortho.png b/docs/en/hardware/HALMET-render-top-ortho.png
similarity index 100%
rename from docs/hardware/HALMET-render-top-ortho.png
rename to docs/en/hardware/HALMET-render-top-ortho.png
diff --git a/docs/hardware/index.md b/docs/en/hardware/index.md
similarity index 100%
rename from docs/hardware/index.md
rename to docs/en/hardware/index.md
diff --git a/docs/index.md b/docs/en/index.md
similarity index 100%
rename from docs/index.md
rename to docs/en/index.md
diff --git a/docs/revisions/HALMET-v1.0.0-schema.pdf b/docs/en/revisions/HALMET-v1.0.0-schema.pdf
similarity index 100%
rename from docs/revisions/HALMET-v1.0.0-schema.pdf
rename to docs/en/revisions/HALMET-v1.0.0-schema.pdf
diff --git a/docs/revisions/HALMET-v1.0.1-schema.pdf b/docs/en/revisions/HALMET-v1.0.1-schema.pdf
similarity index 100%
rename from docs/revisions/HALMET-v1.0.1-schema.pdf
rename to docs/en/revisions/HALMET-v1.0.1-schema.pdf
diff --git a/docs/revisions/index.md b/docs/en/revisions/index.md
similarity index 100%
rename from docs/revisions/index.md
rename to docs/en/revisions/index.md
diff --git a/docs/software/index.md b/docs/en/software/index.md
similarity index 100%
rename from docs/software/index.md
rename to docs/en/software/index.md
diff --git a/docs/tutorials/index.md b/docs/en/tutorials/index.md
similarity index 100%
rename from docs/tutorials/index.md
rename to docs/en/tutorials/index.md
diff --git a/docs/usage/analog_input.svg b/docs/en/usage/analog_input.svg
similarity index 100%
rename from docs/usage/analog_input.svg
rename to docs/en/usage/analog_input.svg
diff --git a/docs/usage/ccs_jumpers.jpg b/docs/en/usage/ccs_jumpers.jpg
similarity index 100%
rename from docs/usage/ccs_jumpers.jpg
rename to docs/en/usage/ccs_jumpers.jpg
diff --git a/docs/usage/digin_pullup_pulldown.svg b/docs/en/usage/digin_pullup_pulldown.svg
similarity index 100%
rename from docs/usage/digin_pullup_pulldown.svg
rename to docs/en/usage/digin_pullup_pulldown.svg
diff --git a/docs/usage/index.md b/docs/en/usage/index.md
similarity index 100%
rename from docs/usage/index.md
rename to docs/en/usage/index.md
diff --git a/docs/usage/solder_jumpers.jpg b/docs/en/usage/solder_jumpers.jpg
similarity index 100%
rename from docs/usage/solder_jumpers.jpg
rename to docs/en/usage/solder_jumpers.jpg
diff --git a/docs/fi/errata/index.md b/docs/fi/errata/index.md
new file mode 100644
index 0000000..dd0b713
--- /dev/null
+++ b/docs/fi/errata/index.md
@@ -0,0 +1,15 @@
+---
+title: Tunnetut virheet
+translated_from: 5ef927a8a1dd611d3215899a52dd6b3bfca86859
+---
+
+# Tunnetut virheet
+
+Tällä sivulla luetellaan kaikki tunnetut laitteistoviat HALMETin eri versioissa.
+
+## Versio 1.0.0
+
+Digitaalitulot 3 ja 4 ovat vaihtaneet paikkaa kortin takapuolen silkkipainossa. Oikea nastajärjestys on:
+
+ - DI3: GPIO 27
+ - DI4: GPIO 26
diff --git a/docs/fi/getting-started/index.md b/docs/fi/getting-started/index.md
new file mode 100644
index 0000000..a2b5f6f
--- /dev/null
+++ b/docs/fi/getting-started/index.md
@@ -0,0 +1,73 @@
+---
+title: Aloitusopas
+translated_from: 75bcdba18bc044c04ce3e220067bf537e069ec82
+---
+
+# Aloitusopas
+
+## Kortin kokoaminen
+
+Jotta liittimet voidaan sijoittaa joustavammin pieniin koteloihin, HALMET-kortit toimitetaan ilman 1-Wire- ja GPIO-liittimiä. Jos aiot käyttää kumpaakaan näistä liitännöistä, sinun on juotettava liitin kiinni korttiin.
+
+Jos tarvitset ohjeita nastarimojen juottamiseen, katso SH-ESP32:n [kokoamisohjeet](https://docs.hatlabs.fi/sh-esp32/pages/getting-started/#revision-1-boards).
+
+## Kortin virransyöttö
+
+HALMET saa käyttöjännitteensä NMEA 2000 -liittimen kautta. Jos aiot liittää HALMETin NMEA 2000 -verkkoon, kortin voi syöttää suoraan verkosta. Kytke silloin NMEA 2000 -johtimet 4-napaiseen irrotettavaan riviliittimeen alla olevan kuvan mukaisesti.
+
+
+{ width="50%" }
+Kytke NMEA 2000 -johtimet liittimeen kuvan mukaisesti.
+
+
+Jos et aio liittää HALMETia NMEA 2000 -verkkoon, käytä samaa liitintä mutta kytke johtimet vain `-`- ja `+`-paikkoihin. Käyttöjännitteen voi ottaa mistä tahansa 5–32 V:n lähteestä. Kortin tyypillinen virrankulutus WiFin ollessa käytössä on 0,07 A 12 V:n jännitteellä.
+
+
+{ width="50%" }
+Kytke käyttöjännitejohtimet liittimeen kuvan mukaisesti.
+
+
+## Kotelot
+
+Veneessä HALMET on aina sijoitettava vesitiiviiseen koteloon.
+Kortti on suunniteltu sopimaan [SH-ESP32-koteloon](https://shop.hatlabs.fi/products/sh-esp32-enclosure). Alla on esimerkki koteloon asennetusta HALMET-kortista.
+
+
+{ width="50%" }
+HALMET asennettuna SH-ESP32-koteloon.
+
+
+SH-ESP32-kotelossa on rajallisesti tilaa liittimille.
+Kummallekin pitkälle sivulle mahtuu käytännössä vain 2–3 paneeliliitintä.
+Jos aiot kytkeä useampia kuin muutaman tulon, suositellaan suurempaa koteloa.
+Esimerkiksi alla näkyvässä Hat Labsin [kompaktissa SH-RPi-kotelossa](https://shop.hatlabs.fi/products/compact-weatherproof-enclosure-for-raspberry-pi-and-sh-rpi-158x90x60-mm) on jo runsaasti tilaa liittimille.
+
+
+{ width="50%" }
+Kompakti SH-RPi-kotelo tarjoaa enemmän tilaa paneeliliittimien sijoitteluun.
+
+
+
+Muita sopivia vesitiiviitä koteloita löytyy helposti mistä tahansa verkkokaupasta. Myös suuremmat ulkokäyttöön tarkoitetut jakorasiat sopivat tarkoitukseen.
+
+### Reikien poraaminen paneeliliittimille
+
+Koteloissa ei yleensä ole valmiiksi porattuja reikiä. Käytä reikiä poratessasi aina kartio- tai porrasterää (sellaista, joka näyttää pieneltä metalliselta joulukuuselta). Tavallinen metalliporanterä puree helposti liian syvälle ja voi halkaista kotelon seinämän.
+
+Kun suunnittelet reikien ja liittimien sijoittelua, jätä riittävästi tilaa liitinmuttereiden kiristämiselle ja liittimen rungolle. Jos aiot asentaa kotelon seinälle, liittimet kannattaa sijoittaa alaspäin, jotta veden pääsy sisään on mahdollisimman epätodennäköistä.
+
+Sopivat reikäkoot eri liittimille:
+
+- PG7-läpivientiholkki ja M12-paneeliliitin (NMEA 2000): 12,5 mm tai 1/2"
+- SP13-paneeliliittimet (sinimustat muoviliittimet): 13 mm
+- PG9-läpivientiholkki: 16 mm tai 5/8"
+
+Kumiset tai silikoniset läpivientikumit mahdollistavat huomattavasti tiheämmän kaapeloinnin kuin paneeliliittimet tai läpivientiholkit. Ne eivät kuitenkaan ole yhtä vesitiiviitä kuin paneeliliittimet tai läpivientiholkit. Lisäksi ne vaativat kaapelin pysyvän kiinnityksen, mikä voi vaikeuttaa järjestelmän huoltamista.
+
+TODO: Lisää kuva läpivientikumista.
+
+### Paneeliliittimien juottaminen
+
+Kun juotat sisäisiä johtimia paneeliliittimiin, käytä aina kutistesukkaa yksittäisten johtimien päällä.
+Muista aina pujottaa kutistesukka johtimeen _ennen_ juottamista...
+Yleensä juotostinaa kannattaa ensin lisätä liittimen nastan koloon ja sitten sulattaa tina uudelleen ja työntää johdin paikalleen.
diff --git a/docs/fi/hardware/index.md b/docs/fi/hardware/index.md
new file mode 100644
index 0000000..77fafac
--- /dev/null
+++ b/docs/fi/hardware/index.md
@@ -0,0 +1,226 @@
+---
+title: Laitteiston kuvaus
+translated_from: 66f9306e0980490684ef1cb989b75a230f6600df
+---
+
+# Laitteisto
+
+## ESP32 lyhyesti
+
+HALMET perustuu tehokkaaseen ESP32-WROOM-32E-mikro-ohjainmoduuliin. ESP32 on kaksiytiminen mikro-ohjain, jossa on sisäänrakennetut WiFi- ja Bluetooth-yhteydet. ESP32 on suosittu valinta IoT-sovelluksiin edullisuutensa, hyvän oheislaitevalikoimansa ja helppokäyttöisyytensä ansiosta.
+
+## Kortin toiminnalliset lohkot
+
+Kortin eri toiminnalliset lohkot kuvataan alla.
+
+
+{ width="60%" }
+HALMETin toiminnalliset lohkot.
+
+
+1. NMEA 2000 -liitäntä sekä käyttöjännitteen syöttö ja suojaus. NMEA 2000
+ -liittimessä on seuraavat suojaukset:
+ - 500 mA:n itsestään palautuva sulake
+ - Napaisuussuojausdiodi
+ - Ylijännite- ja ESD-suojaukseen tarkoitetut TVS-diodit
+ - Kaksivaiheinen häiriösuodatus
+
+2. Teholähde. Hakkuriteholähde, jonka suurin lähtövirta on 2 A.
+
+3. CAN-lähetinvastaanotin NMEA 2000:ta varten. RX- ja TX-LEDit näyttävät
+ CAN-väylän liikenteen.
+
+4. I2C- ja 1-Wire-liitännät lisäantureiden liittämiseen.
+
+5. Käyttöliittymä. Reset-painike, boot-tilan painike (myös yleiskäyttöinen),
+ punainen virran LED ja sininen käyttäjän ohjattava LED.
+
+6. USB 2.0 -liitäntä ohjelmointiin ja virheenjäljitykseen.
+
+7. ESP32-WROOM-32E-moduuli sisäänrakennetuilla WiFi- ja Bluetooth-yhteyksillä.
+ HALMET-kortin moduulissa on 16 Mt flash-muistia.
+
+8. Digitaali- ja analogiatulojen galvaaninen erotus.
+
+9. Analogiatulot. Kortissa on neljä analogiatuloa, joiden erotuskyky on 16
+ bittiä ja suurin tulojännite 33 V. Jokaisessa tulossa on ali- ja
+ ylijännitesuojaus sekä alipäästösuodatus, jonka rajataajuus on 160 Hz
+ mittaushäiriöiden vähentämiseksi.
+
+ Analogiatuloissa on valinnainen 10 mA:n vakiovirtalähde aktiivista
+ vastusmittausta varten. Vakiovirtalähteen voi ottaa käyttöön
+ CCS-hyppyliittimillä.
+
+ Vastusmittaustilassa suurin mitattava vastus on 320 ohmia.
+
+10. Digitaalitulot. HALMETissa on neljä digitaalituloa, joiden suurin tulojännite
+ on +/- 30 V. Tuloissa on Schmitt-liipaisin parantamassa häiriönsietoa.
+
+
+## Galvaaninen erotus
+
+Kortissa on galvaaninen erotus digitaali- ja analogiatulojen sekä
+ESP32-mikro-ohjaimen välillä. Erotus on toteutettu digitaalisilla erottimilla
+I2C:lle ja neljälle digitaalitulolle sekä erotetulla DC/DC-muuntimella, joka
+syöttää erotettua osaa.
+
+Erotuksen ansiosta kortin voi syöttää NMEA 2000 -verkosta ilman maasilmukoiden
+riskiä. Erotus suojaa myös tuloihin kohdistuvilta jännitepiikeiltä ja häiriöiltä.
+
+
+{ width="60%" }
+HALMETin erotusraja. Tuloliittimet on erotettu muusta kortista, eli niillä
+ei ole yhteistä maata kortin muun osan kanssa.
+
+
+## Liittimet
+
+
+
+### Yläpuolen liittimet
+
+1. NMEA 2000 -liitin. Liitin on 4-napainen Phoenix MC 3.81 -yhteensopiva
+ irrotettava riviliitin. Sillä kortti liitetään NMEA 2000 -verkkoon ja
+ syötetään käyttöjännite.
+
+2. 1-Wire-liitin. 1-Wire-liittimeen voi kytkeä 1-Wire-antureita korttiin.
+ Liitin on 3-nastainen, nastaväli 2,54 mm. Liitintä ei ole asennettu korttiin
+ valmiiksi, koska se voi haitata kotelon paneeliliittimien sijoittelua.
+
+3. I2C-liitin. I2C-liittimeen voi kytkeä I2C-antureita korttiin. Liitin on
+ 4-nastainen, nastaväli 2,54 mm.
+
+4. Micro USB -liitin. Liitintä käytetään kortin ohjelmointiin ja
+ virheenjäljitykseen.
+
+5. Kalustamattomat juotospisteet reset- (EN) ja boot-signaaleille (IO0).
+
+6. GPIO-liitin. GPIO-liitin on 2×10-nastainen, nastaväli 2,54 mm. Liitin tuo
+ esiin ESP32:n vapaat GPIO-nastat, ja sitä voi käyttää myös JTAG-liittimenä.
+
+7. Erotetun alueen käyttöjännitteen liitin. Liittimestä voi syöttää ulkoisia
+ laitteita erotetun osan 3V3- ja GND-navoista.
+
+8. Analogiatulojen vakiovirtalähteen (CCS) hyppyliittimen nastat.
+ Vakiovirtalähteen saa käyttöön oikosulkemalla nastat hypyllä.
+
+9. Analogiatulojen liittimet. Liittimet ovat 2-napaisia Phoenix MC 3.81
+ -yhteensopivia irrotettavia riviliittimiä. Niillä kytketään analogiset
+ anturit korttiin.
+
+10. Digitaalitulojen liittimet. Liittimet ovat 2-napaisia Phoenix MC 3.81
+ -yhteensopivia irrotettavia riviliittimiä. Niillä kytketään digitaaliset
+ anturit korttiin.
+
+### Alapuolen liittimet
+
+11. CAN-päätevastuksen juotossilta. Sillan sulkeminen ottaa käyttöön CAN-väylän
+ 120 ohmin päätevastuksen. Älä käytä päätevastusta NMEA 2000 -verkoissa.
+
+12. Alipäästösuotimen juotossilta. Sillan sulkeminen ottaa käyttöön
+ alipäästösuotimen kyseisellä analogiatulolla. Suotimen rajataajuus on
+ 2,3 kHz. Suodinta voi käyttää esimerkiksi kierroslukusignaalin häiriöiden
+ vähentämiseen.
+
+13. Alasvetovastuksen juotossilta. Sillan sulkeminen ottaa käyttöön 100 kohmin
+ alasvetovastuksen kyseisellä digitaalitulolla. Alasvetovastusta voi käyttää
+ avautuvan kytkimen lukemiseen, kun kytkin vetää jännitteen korkeaksi
+ sulkeutuessaan.
+
+14. Ylösvetovastuksen juotossilta. Sillan sulkeminen ottaa käyttöön 100 kohmin
+ ylösvetovastuksen kyseisellä digitaalitulolla. Ylösvetovastusta voi käyttää
+ sulkeutuvan kytkimen lukemiseen, kun kytkin vetää jännitteen matalaksi
+ sulkeutuessaan.
+
+15. ADS1115:n I2C-osoitteen valinnan juotossillat. Silloilla valitaan
+ ADS1115-AD-muuntimen I2C-osoite. Niillä vältetään osoitteiden törmäykset,
+ kun samaan I2C-väylään on kytketty useita ADS1115-muuntimia. Juotospisteitä
+ voi käyttää myös lisä-I2C-laitteiden kytkemiseen kortin erotetulle alueelle.
+
+### GPIO-taulukko
+
+HALMET varaa osan GPIO-nastoista tulo-oheislaitteille. Vapaat GPIO-nastat on tuotu
+esiin 2×10-nastaiseen GPIO-liittimeen. Seuraavassa taulukossa on lueteltu
+GPIO-nastat ja niiden toiminnot.
+
+| GPIO | Toiminto | Huomautukset |
+| ------: | :---------- | :------------------------------------------------- |
+| 0 | Boot-painike | Siirtyy käynnistyslataajaan, kun vedetään matalaksi |
+| 1 | TXD0 | Datan lähetys USB:hen |
+| 2 | LED | Kortin punainen LED |
+| 3 | RXD0 | Datan vastaanotto USB:stä |
+| 4 | 1-Wire DQ | 1-Wiren datalinja |
+| 5 | - | Vapaa GPIO-liittimessä |
+| 12 | - / TDI | Vapaa GPIO-liittimessä. Vaihtoehtoisesti: JTAG TDI |
+| 13 | - / TCK | Vapaa GPIO-liittimessä. Vaihtoehtoisesti: JTAG TCK |
+| 14 | - / TMS | Vapaa GPIO-liittimessä. Vaihtoehtoisesti: JTAG TMS |
+| 15 | - / TDO | Vapaa GPIO-liittimessä. Vaihtoehtoisesti: JTAG TDO |
+| 16 | - | Vapaa GPIO-liittimessä |
+| 17 | - | Vapaa GPIO-liittimessä |
+| 18 | CAN RX | Vastaanotto NMEA 2000:sta |
+| 19 | CAN TX | Lähetys NMEA 2000:een |
+| 21 | I2C SDA | I2C:n datalinja. Käytössä analogiatuloille |
+| 22 | I2C SCL | I2C:n kellolinja. Käytössä analogiatuloille |
+| 23 | DI1 | Digitaalitulo 1 |
+| 25 | DI2 | Digitaalitulo 2 |
+| 27 | DI3 | Digitaalitulo 3 |
+| 26 | DI4 | Digitaalitulo 4 |
+| 32 | - | Vapaa GPIO-liittimessä |
+| 33 | - | Vapaa GPIO-liittimessä |
+| 34 | - | Vapaa GPIO-liittimessä |
+| 35 | - | Vapaa GPIO-liittimessä |
+| 36 (VP) | Vain tulo | Vapaa GPIO-liittimessä |
+| 39 (VN) | Vain tulo | Vapaa GPIO-liittimessä |
+
+
+## Teholähde
+
+Kortin sallittu syöttöjännitealue on 5–32 V. Tyypillinen virrankulutus on 90 mA
+12 V:n jännitteellä WiFi-moduulin ollessa käytössä (vastaa 1,1 W:n tehoa).
+
+## NMEA 2000
+
+NMEA 2000 on laajalti käytetty tiedonsiirtostandardi, jolla liitetään antureita, ohjaimia ja näyttölaitteita veneissä ja laivoissa. Se perustuu CAN-väylään (Controller Area Network), joka on ajoneuvoväylästandardi ja mahdollistaa laitteiden keskinäisen viestinnän ilman isäntätietokonetta.
+
+Kortti täyttää NMEA 2000 -standardin vaatimukset niin kauan kuin yhtäkään erottamatonta liitintä ei ole kytketty muihin maahan kytkettyihin laitteisiin. Esimerkiksi 1-Wire-lämpötila-anturia, jossa on pitkä kaapeli, voi käyttää, koska sillä ei ole yhteistä maata muiden laitteiden kanssa. Sen sijaan I2C-AD-muuntimen kytkeminen erottamattomaan I2C-liittimeen rikkoisi NMEA 2000 -yhteensopivuuden.
+
+TODO: NMEA 2000:n GPIO-nastajärjestys
+
+## Tilan LEDit
+
+HALMET-kortilla on kaksi painiketta ja kaksi LEDiä. Painikkeet on merkitty tunnuksilla Reset ja Boot. Reset-painike käynnistää kortin uudelleen vetämällä ESP32:n Enable-nastan matalaksi. Boot-painike on kytketty GPIO0:aan, ja sillä voi pakottaa moduulin latausmoodiin laitteen käynnistyksen aikana. Muulloin sitä voi käyttää tavallisena painiketulona.
+
+LEDejä ei ole erikseen merkitty. Punainen LED palaa aina, kun kortilla on 3,3 V:n käyttöjännite. Sininen LED on kytketty GPIO2:een (nasta, jota ESP32-kehityskorteissa yleisesti käytetään LEDille). Käyttäjän ohjelmat voivat ohjata sitä osoittamaan laitteen tilaa.
+
+## 1-Wire
+
+1-Wire on Dallas Semiconductorin suunnittelema laiteväyläjärjestelmä; yhtiön on sittemmin ostanut Maxim Integrated Products. Vaikka 1-Wire on hidas protokolla ja tukee vain enintään 16,3 kbit/s:n nopeuksia, se on hyvin yksinkertainen toteuttaa ja toimii pitkilläkin etäisyyksillä. Sitä käytetään yleisesti lämpötila-antureissa ja muissa yksinkertaisissa mittalaitteissa.
+
+HALMETin 1-Wire-toteutuksessa on ESD- ja RF-häiriösuodatus sekä alipäästösuodatus verkon luotettavuuden parantamiseksi.
+
+Huomaa, että 1-Wiren datanasta (merkintä ”DQ”) on fyysisesti kytketty GPIO4:ään, joten käytä ohjelmassasi GPIO4:ää kaikelle 1-Wire-datalle.
+
+## I2C
+
+I2C (Inter-Integrated Circuit) on hyvin suosittu synkroninen sarjaliikenneväylä, jota käytetään yleisesti useiden erilaisten piirien liittämiseen. Se käyttää kahta datajohdinta käyttöjännitteen ja maan lisäksi.
+
+HALMET käyttää I2C:tä sisäisesti ADS1115-AD-muuntimelle. I2C-väylä on tuotu myös 4-nastaiseen liittimeen lisä-I2C-laitteiden kytkemistä varten.
+
+I2C-väylä on kytketty ESP32:n nastoihin GPIO21 (SDA) ja GPIO22 (SCL). Nämä ovat Arduinon ESP32-ympäristön oletusnastat I2C:lle, mutta poikkeavat SH-ESP32:n oletusnastoista.
diff --git a/docs/fi/index.md b/docs/fi/index.md
new file mode 100644
index 0000000..c1f4b24
--- /dev/null
+++ b/docs/fi/index.md
@@ -0,0 +1,35 @@
+---
+title: Johdanto
+translated_from: 2ad10049c9c3d0b5f6fb78356500eaa25670febd
+---
+
+# Johdanto
+
+HALMET (Hat Labs Marine Engine & Tank interface) on kehityskortti moottori- ja tankkianturien liittämiseen veneissä ja muissa ajoneuvoissa. Sillä voi lukea digitaalisia ja analogisia antureita sekä liittyä muihin laitteisiin NMEA 2000-, WiFi-, Bluetooth-, I2C-, 1-Wire- tai GPIO-liitäntöjen kautta.
+
+
+{ width="60%" }
+Kuva HALMETista
+
+
+## Tärkeimmät ominaisuudet
+
+- **Neljä digitaalituloa**: HALMETissa on neljä digitaalituloa digitaalisten hälytyssignaalien lukemiseen tai laskureiksi. Tulot kestävät jännitteitä -32 V:n ja +32 V:n välillä. Digitaalituloilla voi havaita sekä signaalitasoja että ajassa muuttuvia signaaleja, kuten moottorin kierroslukua, polttoaineen virtausta tai ketjulaskurin pulsseja.
+
+- **Neljä analogiatuloa**: HALMETissa on neljä analogiatuloa analogisten antureiden lukemiseen. Tulot kestävät jännitteitä -32 V:n ja +32 V:n välillä, ja mittausalue on 0–32 V. Tulot on kytketty 16-bittiseen ADS1115-AD-muuntimeen. Analogiatuloja voi käyttää sekä passiiviseen jännitemittaukseen että aktiiviseen vastusmittaukseen.
+
+- **NMEA 2000 -yhteensopiva**: HALMET on täysin yhteensopiva NMEA 2000 -standardin kanssa. Kortin voi liittää NMEA 2000 -verkkoon sisäänrakennetun NMEA 2000 -liitännän kautta.
+
+- **I2C-, 1-Wire- ja GPIO-liitännät**: HALMETissa on 4-nastainen I2C-liitäntä, 3-nastainen 1-Wire-liitäntä ja 13 vapaata yleiskäyttöistä tulo-/lähtönastaa (GPIO).
+
+- **WiFi- ja Bluetooth-yhteydet**: HALMETissa on integroitu ESP32-WROOM-32E-moduuli, jossa on WiFi- ja Bluetooth-yhteydet. Ne mahdollistavat sekä liittymisen olemassa oleviin WiFi-verkkoihin että WiFi-tukiaseman luomisen, jolloin korttiin voi ottaa yhteyden suoraan.
+
+- **ESP32-WROOM-32E ja 16 Mt flash-muistia**: ESP32-WROOM-32E-moduuli tarjoaa runsaasti laskentatehoa ja muistia vaativimpiinkin sovelluksiin. 16 megatavun flash-muistiin voi tallentaa suuria määriä dataa paikallisesti.
+
+- **Laaja käyttöjännitealue**: HALMETia voi syöttää turvallisesti ajoneuvoissa ja veneissä yleisestä 12 V:n tai 24 V:n järjestelmästä. HALMET kestää 5 V:n ja 32 V:n väliset syöttöjännitteet.
+
+HALMET on avointa laitteistoa, lisensoitu Creative Commons Nimeä-JaaSamoin 4.0 Kansainvälinen -lisenssillä.
+
+## Laitteiston hankkiminen
+
+HALMET-kortteja voi ostaa [Hat Labs Oy:ltä](https://shop.hatlabs.fi). Kaikki suunnittelutiedostot ovat myös saatavilla [HALMETin laitteistorepositoriossa GitHubissa](https://github.com/hatlabs/halmet-hardware/).
diff --git a/docs/fi/revisions/index.md b/docs/fi/revisions/index.md
new file mode 100644
index 0000000..088412b
--- /dev/null
+++ b/docs/fi/revisions/index.md
@@ -0,0 +1,23 @@
+---
+title: Laitteistoversiot
+translated_from: b998495eda7b60a66b73cfc622807d9d9c4e8043
+---
+
+# Laitteistoversiot
+
+## Johdanto
+
+Tällä sivulla kuvataan kortin eri versiot ja tarjotaan linkit kytkentäkaavioihin. Suunnittelutiedostojen koko historia on saatavilla [HALMET-hardware -GitHub-repositoriossa](https://github.com/hatlabs/HALMET-hardware).
+
+
+## Versio 1.0.0
+
+Ensimmäinen julkaistu versio.
+
+Kytkentäkaaviot: [HALMET-v1.0.0-schema.pdf](HALMET-v1.0.0-schema.pdf)
+
+## Versio 1.0.1
+
+Silkkipainon korjauksia ja parannuksia. Erotetulle alueelle lisätty 3V3- ja GND-juotospisteet.
+
+Kytkentäkaaviot: [HALMET-v1.0.1-schema.pdf](HALMET-v1.0.1-schema.pdf)
diff --git a/docs/fi/software/index.md b/docs/fi/software/index.md
new file mode 100644
index 0000000..3cdb1b3
--- /dev/null
+++ b/docs/fi/software/index.md
@@ -0,0 +1,16 @@
+---
+title: Ohjelmisto
+translated_from: 4a66b76add4ab5ef880e7daff125e78b9d34e212
+---
+
+# Ohjelmisto
+
+## Johdanto
+
+HALMET on kehityskortti, joten siinä ei ole valmiiksi asennettua ohjelmistoa. Sopiva ohjelmisto pitää asentaa itse. Se ei ole vaikeaa, mutta aiempi kokemus mikro-ohjainkorteista, kuten Arduinosta tai ESP32 Devkitistä, on suositeltavaa.
+
+Esimerkkifirmware HALMETille löytyy [HALMET-example-firmware -GitHub-repositoriosta](https://github.com/hatlabs/HALMET-example-firmware).
+
+Huomaa, että ohjelmistokehitykseen liittyviin kysymyksiin annetaan tukea vain [Hat Labsin keskustelufoorumilla](https://github.com/hatlabs/discussions/discussions).
+
+Lisää ohjeita tulossa pian.
diff --git a/docs/fi/tutorials/index.md b/docs/fi/tutorials/index.md
new file mode 100644
index 0000000..ead3f03
--- /dev/null
+++ b/docs/fi/tutorials/index.md
@@ -0,0 +1,6 @@
+---
+title: Ohjeet ja esimerkkiprojektit
+translated_from: 879db3c0579a621737332a0896bb66329d0f188c
+---
+
+HALMETin ohjeet ja esimerkkiprojektit listataan tällä sivulla.
diff --git a/docs/fi/usage/index.md b/docs/fi/usage/index.md
new file mode 100644
index 0000000..77e9c17
--- /dev/null
+++ b/docs/fi/usage/index.md
@@ -0,0 +1,98 @@
+---
+title: Käyttö
+translated_from: 0d5855d63a22b19308b3b70c9481dfc441864197
+---
+
+# Käyttö
+
+## Yleisiä käyttötapauksia
+
+Tässä osiossa on käytännön tietoa erityyppisten antureiden lukemisesta ja HALMETin liittämisestä muihin laitteisiin.
+
+### Ohjelmiston asennus
+
+HALMET on kehityskortti, eikä siinä ole valmiiksi asennettua ohjelmistoa.
+Sopiva ohjelmisto pitää asentaa itse. Se ei ole vaikeaa, mutta aiempi kokemus mikro-ohjainkorteista, kuten Arduinosta tai ESP32 Devkitistä, on suositeltavaa.
+
+HALMETin dokumentaatiossa oletetaan, että käytössä on [HALMETin esimerkkifirmware](https://github.com/hatlabs/HALMET-example-firmware). Se perustuu [SensESP](https://signalk.org/SensESP/) -kehykseen ja tarjoaa suhteellisen suoraviivaisen pääsyn kortin ominaisuuksiin.
+
+[SensESP:n aloitusopas](https://signalk.org/SensESP/pages/getting_started/) sisältää yksityiskohtaiset ohjeet firmwaren kääntämiseen ja asentamiseen tarvittavan kehitysympäristön asennukseen. Ohjeet on kirjoitettu yleisille ESP32-laitteille, mutta ne pätevät myös HALMETiin. Käytä vain [HALMETin esimerkkifirmwarea](https://github.com/hatlabs/HALMET-example-firmware) SensESP:n projektipohjan sijaan.
+
+Huomaa, että vaikka SensESP:n dokumentaatiossa oletetaan Signal K:n käyttö, HALMET on täysin käyttökelpoinen myös itsenäisenä NMEA 2000 -laitteena.
+
+Jos et halua käyttää SensESP:tä, voit myös tehdä oman firmwaresi Arduino IDE:llä tai ESP-IDF:llä. Moniin käyttötapauksiin myös ESPHome on erinomainen vaihtoehto.
+
+**HUOMAA:** HALMETin GPIO-nastojen käyttö poikkeaa hieman sekä ESP32 Devkitin että SH-ESP32:n nastajärjestyksestä. Jos otat käyttöön jotain muuta ohjelmistoa kuin HALMETin esimerkkifirmwaren, sinun on tarkistettava nastojen käyttö. Lisätietoja on [GPIO-taulukossa](../hardware/index.md#gpio-taulukko).
+
+### Digitaalitulojen käyttö
+
+HALMETissa on neljä digitaalituloa. Niitä voi käyttää digitaalisten hälytyssignaalien lukemiseen tai laskureina. Tässä osiossa kuvataan tulojen käyttö erilaisissa yleisissä käyttötapauksissa. Ohjeissa oletetaan, että käytössä on HALMETin esimerkkifirmware.
+
+Digitaalitulot D1–D4 on kytketty GPIO-nastoihin 23, 25, 27 ja 26 tässä järjestyksessä. Tulot kestävät jännitteitä -32 V:n ja +32 V:n välillä. Korkean signaalin havaitsemisen kynnysjännite on noin 1,55 V ja hystereesi noin 0,7 V.
+
+### Liittäminen digitaalisiin hälytyksiin
+
+Tässä osiossa kuvataan, miten HALMET liitetään erilaisiin päälle/pois-tyyppisiin signaaleihin, kuten moottori- tai pilssihälytyksiin.
+
+#### Laitteiston asennus
+
+Yleensä erilaiset päälle/pois-tyyppiset signaalit, kuten moottori- tai pilssihälytykset, voidaan kytkeä suoraan HALMETin digitaalituloihin. Ylös- tai alasveto voi olla tarpeen signaalityypistä riippuen.
+
+Alla olevan kuvan esimerkissä (a) piirissä on jo hehkulamppu. Kun kytkin on auki, hehkulamppu vetää D1:n jännitteen alas. Erillistä alasvetoa ei tarvita.[^1] Esimerkissä (b) piirissä ei sen sijaan ole muuta kuormaa. Jos kytkin on auki, D2:n jännite jää kelluvaksi ja tulo on satunnaisesti joko korkea tai matala. Tässä tapauksessa sisäinen alasvetovastus on otettava käyttöön sulkemalla kortin takapuolen juotossilta.
+
+
+{ width="60%" }
+Digitaalitulot eri käyttötapauksissa. (a) Piirissä on jo lamppu. (b) Piirissä ei ole muuta kuormaa, kytkin vetää signaalin korkeaksi sulkeutuessaan. (c) Kytkin vetää signaalin matalaksi sulkeutuessaan.
+
+
+[^1]: Jos paneelin valot on toteutettu LEDeillä, LEDien yli oleva jännitehäviö ei välttämättä riitä vetämään jännitettä riittävän alas. Tällöin alasvetovastus on otettava käyttöön.
+
+
+{ width="60%" }
+Kortin takapuolen juotossillat voidaan sulkea, jolloin sisäänrakennetut ylös- tai alasvetovastukset tulevat käyttöön.
+
+
+Vastaavasti jos kytkin vetää signaalin matalaksi sulkeutuessaan kuten esimerkissä (c), sisäinen ylösveto voi olla tarpeen ottaa käyttöön.
+
+Jos hälytyskytkimet ovat sulkeutuvia, tilanne on päinvastainen. Kun kytkin avautuu, tulojännite vedetään ylös tai alas piiristä riippuen. Tällöin sisäinen ylös- tai alasveto voi olla tarpeen ottaa käyttöön.
+
+#### Ohjelmiston asennus
+
+HALMETin esimerkkifirmware tarjoaa `ConnectAlarmSender()`-apumetodin digitaalitulojen määrittämiseen ja kytkemiseen. Katso `main.cpp` riviltä 177 eteenpäin. Sekä aktiivisesti korkeat että aktiivisesti matalat signaalit ovat tuettuja.
+
+### Digitaalitulot laskureina
+
+HALMETin digitaalituloja voi käyttää myös laskureina. Tämä on hyödyllistä esimerkiksi moottorin kierrosten tai ketjulaskurin pulssien laskemiseen.
+
+#### Laitteiston asennus
+
+Yleensä tällaisia antureita ohjataan aktiivisesti molempiin suuntiin, joten ylös- tai alasvetoa ei tarvita. Jos liität HALMETin matalaimpedanssiseen lähtöön, kuten laturin W-napaan, on suositeltavaa lisätä sarjaan sulake suojaamaan johdinta hankautumisesta tai muusta vauriosta johtuvilta oikosuluilta. Muuten anturin voi kytkeä suoraan digitaalituloon.
+
+Jos pulssilähde on hyvin häiriöinen ja kierroslukulukema heittelee, alipäästösuotimen voi ottaa käyttöön sulkemalla kortin takapuolen LP-juotossillan. Alipäästösuotimen rajataajuus on noin 2,3 kHz, mikä sopii esimerkiksi laturin W-navan kaltaisiin tuloihin.
+
+#### Ohjelmiston asennus
+
+HALMETin esimerkkifirmware toteuttaa pulssilaskurin, jonka voi ottaa käyttöön millä tahansa digitaalitulolla tai kaikilla. Katso esimerkkiasetukset tiedostosta `main.cpp` riviltä 214 eteenpäin.
+
+### Analogiatulojen käyttö
+
+HALMETissa on neljä analogiatuloa, joita voi käyttää joko passiiviseen jännitemittaukseen tai aktiiviseen vastusmittaukseen. Tässä osiossa kuvataan tulojen käyttö erilaisissa yleisissä käyttötapauksissa.
+
+#### Laitteiston asennus
+
+Analogiatulot A1–A4 on kytketty ADS1115-AD-muuntimeen. ADS1115:n erotuskyky on 16 bittiä ja suurin näytteenottotaajuus 860 näytettä sekunnissa. HALMETin analogiatuloissa on kuitenkin voimakas alipäästösuodin, jonka rajataajuus on noin 160 Hz. Se riittää silti hyvin fysikaalisten anturien, kuten tankin pinta-anturien tai moottorin paineanturien, lukemiseen.
+
+Alla olevan kuvan esimerkissä (a) on moottoripaneelin mittari kytkettynä vastusanturiin. Moottoripaneelin mittarit ovat rakenteeltaan yleensä joko termostaattisia tai magneettisia. Kummassakin tapauksessa mittari ja anturi toimivat jännitteenjakajana, ja anturin yli oleva jännite on verrannollinen mitattavaan suureeseen. Tämän jännitteen voi mitata HALMETin analogiatuloilla häiritsemättä alkuperäisen mittarin toimintaa. Jännitteenjakajan takia jännite ei välttämättä korreloi lineaarisesti mitattavan suureen kanssa, mutta tämän voi kompensoida ohjelmallisesti.
+
+
+{ width="60%" }
+Analogiatulojen kytkeminen olemassa olevan mittarin kanssa ja ilman. (a) Kun mittari on jo olemassa, käytä HALMETia passiivisessa jännitemittaustilassa. (b) Kun muuta laitetta ei ole, käytä HALMETia aktiivisessa vastusmittaustilassa.
+
+
+
+Esimerkissä (b) mittaria ei ole. Anturi on kytketty suoraan HALMETin analogiatuloon. Tällöin HALMETin on tuotettava anturille herätejännite. HALMET toteuttaa vastusmittauksen 10 mA:n vakiovirtalähteellä. 10 mA:n virta synnyttää 100 ohmin vastuksen yli 1 voltin jännite-eron, joten suurin mitattava vastus on noin 300 ohmia. Vakiovirtalähde otetaan käyttöön asettamalla hyppy CCS-hyppyliittimen (constant current source) nastapariin. Katso alla oleva kuva.
+
+
+{ width="60%" }
+Kuvassa vakiovirtalähde on otettu käyttöön analogiatuloille A2 ja A4.
+
diff --git a/mkdocs.yml b/mkdocs.yml
index 0d40704..289751e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -5,7 +5,7 @@ site_author: Hat Labs
repo_url: https://github.com/hatlabs/HALMET
repo_name: hatlabs/HALMET
-edit_uri: edit/main/halmet-docs/docs/
+edit_uri: edit/main/docs/
theme:
name: material
@@ -30,11 +30,34 @@ theme:
plugins:
- search
- - print-site:
- add_cover_page: true
- add_print_site_banner: true
- add_to_navigation: false
- print_page_title: "HALMET: Hat Labs Marine Engine & Tank Interface"
+ - i18n:
+ docs_structure: folder
+ languages:
+ - locale: en
+ name: English
+ default: true
+ build: true
+ - locale: fi
+ name: Suomi
+ build: true
+ site_name: "HALMET: Hat Labsin moottori- ja tankkimittausliitäntä"
+ site_description: HALMETin käyttöopas — ESP32-pohjainen moottori- ja tankkianturien mittauskortti veneisiin
+ admonition_translations:
+ note: Huomio
+ warning: Varoitus
+ tip: Vinkki
+ info: Tietoa
+ danger: Vaara
+ example: Esimerkki
+ nav_translations:
+ Introduction: Johdanto
+ Getting Started: Aloitusopas
+ Usage: Käyttö
+ Hardware: Laitteisto
+ Software: Ohjelmisto
+ Tutorials and Examples: Ohjeet ja esimerkit
+ Hardware Revisions: Laitteistoversiot
+ Errata: Tunnetut virheet
markdown_extensions:
- admonition
diff --git a/pyproject.toml b/pyproject.toml
index 9776a10..6a654d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,5 +6,5 @@ requires-python = ">=3.11"
dependencies = [
"mkdocs-material>=9.5",
"click<8.3",
- "mkdocs-print-site-plugin>=2.8",
+ "mkdocs-static-i18n>=1.2",
]
diff --git a/scripts/check_anchors.py b/scripts/check_anchors.py
new file mode 100644
index 0000000..8ad9591
--- /dev/null
+++ b/scripts/check_anchors.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""Verify that every internal anchor in the built site resolves to a real id.
+
+Anchors are generated from heading text, so translating a heading changes its
+slug and silently breaks every link pointing at it — including links on pages
+that were not touched, which is why this is a delayed fault: a cross-page anchor
+keeps working until its *target* page is translated. `mkdocs build --strict`
+does not validate anchors at all.
+
+Run against a built site directory. Exit status is 1 if any anchor is broken.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from urllib.parse import unquote, urldefrag
+
+HREF = re.compile(r'href="([^"]+)"')
+ID = re.compile(r'\sid="([^"]+)"')
+
+
+def collect_pages(site: str) -> dict[str, set[str]]:
+ """Map each built page to the set of element ids it defines."""
+ ids: dict[str, set[str]] = {}
+ for root, _, files in os.walk(site):
+ for name in files:
+ if name.endswith(".html"):
+ path = os.path.join(root, name)
+ text = open(path, encoding="utf-8").read()
+ ids[os.path.realpath(path)] = set(ID.findall(text))
+ return ids
+
+
+def resolve(href: str, page: str, site: str, base: str) -> str | None:
+ """Resolve an href to the built file it points at, or None if not ours."""
+ target, _ = urldefrag(href)
+ target = unquote(target)
+ if not target:
+ return os.path.realpath(page)
+ if target.startswith("/"):
+ if not target.startswith(base):
+ return None
+ path = os.path.normpath(os.path.join(site, target[len(base):]))
+ else:
+ path = os.path.normpath(os.path.join(os.path.dirname(page), target))
+ if not path.endswith(".html"):
+ path = os.path.join(path, "index.html")
+ return os.path.realpath(path)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("site", nargs="?", default="site")
+ parser.add_argument("--base", default="/halpi2/",
+ help="path component of site_url, for root-absolute links")
+ args = parser.parse_args()
+
+ ids = collect_pages(args.site)
+ if not ids:
+ # Passing on an empty site would be a false green: the build produced
+ # nothing, or the path is wrong, and neither is "all anchors resolve".
+ print(f"No built pages found under {args.site!r} — nothing to check.",
+ file=sys.stderr)
+ return 2
+
+ broken: list[tuple[str, str, str]] = []
+ checked = 0
+
+ for page in sorted(ids):
+ for href in HREF.findall(open(page, encoding="utf-8").read()):
+ if href.startswith(("http://", "https://", "mailto:", "data:")):
+ continue
+ _, fragment = urldefrag(href)
+ if not fragment:
+ continue
+ target = resolve(href, page, args.site, args.base)
+ if target is None:
+ continue
+ checked += 1
+ relative = os.path.relpath(page, args.site)
+ if target not in ids:
+ broken.append((relative, href, "target page does not exist"))
+ elif unquote(fragment) not in ids[target]:
+ broken.append((relative, href, "no such anchor on the target page"))
+
+ print(f"Checked {checked} anchor links across {len(ids)} pages.")
+ if broken:
+ print(f"\n{len(broken)} broken:\n")
+ for page, href, why in broken:
+ print(f" {page}\n -> {href} ({why})")
+ return 1
+ print("All anchors resolve.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_glossary.py b/scripts/check_glossary.py
new file mode 100644
index 0000000..aae0b56
--- /dev/null
+++ b/scripts/check_glossary.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Check that a translation actually uses the terms its glossary prescribes.
+
+A glossary read before translating looks followed afterwards, because rereading
+one's own text confirms whatever it already says. Every language branch so far
+reached review with a term the glossary defines and the pages ignore — a second
+name for the same connector, one page apart, which no reader can reconcile.
+
+The check is indirect but cheap: if a glossary term appears in the English
+source and its prescribed translation appears nowhere in the target language,
+some other word is doing that job. Run it before opening a pull request.
+
+It finds a term that is never used, not a term that has acquired a rival. German
+says both `Spannungsausfall` and `Stromausfall` for *blackout* and passes here,
+because the prescribed word does appear. Catching that needs the rival named,
+which is what the glossary cannot know in advance.
+
+Exit status is 1 if any prescribed term is unused.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+import unicodedata
+from pathlib import Path
+
+GLOSSARIES = {
+ "fi": "finnish-glossary.md",
+}
+
+ROW = re.compile(r"^\| *`?([^|`]+?)`? *\| *`?([^|`]+?)`? *\|")
+SHORTEST_TERM = 5
+# An English term used once may be phrased around; twice is a pattern.
+MIN_ENGLISH_USES = 2
+
+
+def read_pages(directory: Path) -> str:
+ """Concatenate a language's markdown with code and frontmatter removed."""
+ out = []
+ for page in sorted(directory.rglob("*.md")):
+ raw = page.read_text(encoding="utf-8")
+ text = re.sub(r"^---\n.*?\n---\n", "", raw, flags=re.S)
+ text = re.sub(r"```.*?```", " ", text, flags=re.S)
+ out.append(re.sub(r"`[^`\n]*`", " ", text))
+ return fold("\n".join(out).lower())
+
+
+def terms(glossary: Path) -> list[tuple[str, str]]:
+ """Extract (english, translation) pairs from the glossary tables."""
+ pairs = []
+ for line in glossary.read_text(encoding="utf-8").splitlines():
+ row = ROW.match(line)
+ if not row:
+ continue
+ english, translated = row.group(1).strip(), row.group(2).strip()
+ if english.lower().startswith("english") or set(english) <= set(":- "):
+ continue
+ pairs.append((english, translated))
+ return pairs
+
+
+def fold(text: str) -> str:
+ """Flatten the spelling differences that inflection introduces.
+
+ Romance plurals move accents around — `tapón` becomes `tapones`, `imagen`
+ becomes `imágenes` — and Italian sets its apostrophe as U+2019 where a
+ glossary cell is typed with U+0027. Comparing the letters underneath keeps
+ those from reading as a term the pages never used.
+ """
+ text = text.replace("’", "'").replace("ʼ", "'")
+ return "".join(
+ c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c)
+ )
+
+
+def alternatives(term: str) -> list[str]:
+ """Split a glossary cell into the forms that would each satisfy it."""
+ term = re.sub(r"\s*\([^)]*\)", "", term).lower()
+ return [part.strip() for part in term.split("/") if part.strip()]
+
+
+def inflectable(term: str) -> re.Pattern[str]:
+ """Match a term in whatever form a sentence needs.
+
+ Every word may take an ending, not just the last one: Finnish inflects both
+ halves of `vapaa tila` and French pluralises both halves of `bouchon
+ obturateur`, so anchoring on the phrase as written finds neither. A verb
+ phrase also takes its object in the middle — `aseta CM5 uudelleen
+ paikalleen` — so a couple of words are allowed to intervene.
+ """
+ words = [re.escape(w[: max(3, len(w) - 3)]) + r"\w*" for w in fold(term).split()]
+ return re.compile(r"(?:\W+\w+){0,2}\W+".join(words))
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "language", choices=sorted(GLOSSARIES), help="target language code"
+ )
+ parser.add_argument("--docs", default="docs", help="documentation root")
+ parser.add_argument(
+ "--glossaries",
+ default="solutions/translation",
+ help="directory holding the glossaries",
+ )
+ args = parser.parse_args()
+
+ english = read_pages(Path(args.docs) / "en")
+ translated = read_pages(Path(args.docs) / args.language)
+ glossary = Path(args.glossaries) / GLOSSARIES[args.language]
+
+ checked, unused = 0, []
+ for source, target in terms(glossary):
+ wanted = [w for w in alternatives(source) if len(w) >= SHORTEST_TERM]
+ have = [h for h in alternatives(target) if len(h) >= SHORTEST_TERM]
+ if not wanted or not have:
+ continue
+ uses = sum(english.count(w) for w in wanted)
+ if uses < MIN_ENGLISH_USES:
+ continue
+ checked += 1
+ if not any(inflectable(h).search(translated) for h in have):
+ unused.append((source, target, uses))
+
+ print(f"Checked {checked} glossary terms against docs/{args.language}.")
+ if unused:
+ print(f"\n{len(unused)} prescribed but unused — something else took over:\n")
+ for source, target, uses in unused:
+ print(f" {source} -> {target} (English {uses}×, translation never)")
+ return 1
+ print("Every prescribed term is in use.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_typography.py b/scripts/check_typography.py
new file mode 100644
index 0000000..04628cc
--- /dev/null
+++ b/scripts/check_typography.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""Count the typography rules a translation has to obey, per language.
+
+Written after two naive greps produced only false positives: searching for the
+character pair »…« in Norwegian matches the gap *between* two correct «…» pairs,
+and searching for a space before a colon matches English comments inside code
+fences. Both looked like defects and neither was one.
+
+So quotations are checked by walking the marks in order and requiring them to
+alternate open, close, open, close — which is what "the pairs are the right way
+round" actually means — and everything is measured with code fences, inline code
+and admonition syntax removed first.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+# Which mark opens a quotation, and which closes it, per language.
+QUOTES = {
+ "fi": ("”", "”"), # ”…” — the same character opens and closes
+}
+# French is the one language that *requires* a space before ; : ! ? — and
+# requires it to be unbreakable, so the line never breaks before the mark.
+# Everywhere else any space there is an error, which is why this cannot be one
+# rule for all: applying the French habit elsewhere is a known leak, and
+# applying the majority rule to French would flag every correct sentence.
+# French is the one language that requires a space before ; : ! ? and requires
+# it unbreakable. No language here does, but the exception is kept named so
+# that adding French later is a one-line change rather than a rediscovery.
+SPACE_REQUIRED: set[str] = set()
+PLAIN_SPACE_BEFORE_PUNCT = re.compile(r"\u0020[;:!?]")
+# German compounds a multi-word proper name with hyphens throughout —
+# NMEA-2000-Netzwerk, Signal-K-Server — and its glossary calls a missing hyphen
+# there the most visible marker of a translation done by someone who does not
+# write German. Every other language treats that same chain as an error, and a
+# hyphen at the *junction* between a product name and a common noun
+# (HaLOS-avbilder) is right in the Germanic languages and wrong in the Romance
+# ones. One rule cannot serve all three cases, so each is scoped to where its
+# glossary asks for it.
+HYPHEN_CHAINS = re.compile(r"NMEA-2000|Signal-K|Raspberry-Pi|Compute-Module")
+# German compounds a proper name with hyphens throughout — NMEA-2000-Netzwerk —
+# where every other language treats that chain as an error.
+CHAINS_ALLOWED: set[str] = set()
+JUNCTION_HYPHEN = re.compile(
+ r"\b(?:HALPI2|HaLOS|NMEA 2000|Signal K|Raspberry Pi|E7T)-"
+ r"[a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1\u00e0\u00e8\u00ec\u00f2\u00f9]"
+)
+# Romance languages take no hyphen between a product name and a common noun;
+# Finnish and the other Germanic languages take one at the junction.
+JUNCTION_FORBIDDEN: set[str] = set()
+SPACE_BEFORE_PUNCT = re.compile(r"[ ][;:!?]")
+
+
+def prose(text: str) -> str:
+ """The text a reader sees, with everything that is markup taken out.
+
+ Inline code becomes a placeholder rather than nothing: deleting it joins the
+ words on either side and manufactures a space before the next punctuation
+ mark, which is exactly the false positive this function exists to avoid.
+ """
+ text = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.S)
+ text = re.sub(r"```.*?```", "\n", text, flags=re.S)
+ text = re.sub(r"`[^`\n]*`", "X", text)
+ text = re.sub(r'^!!! \w+ ".*"$', "", text, flags=re.M) # admonition syntax quotes
+ text = re.sub(r"\]\([^)]*\)", "]", text) # link targets
+ # A table's delimiter row carries the column alignment as colons — | ---: |
+ # — which reads as a space before a colon and is not prose at all.
+ text = re.sub(r"^[|\s:-]+$", "", text, flags=re.M)
+ # Repository names and filenames are identifiers that happen to contain
+ # hyphens — HALPI2-hardware, HALPI2-schematic_v0.6.1.pdf — and reading them
+ # as compounds of the target language invents defects that are not there.
+ text = re.sub(r"https?://\S+", "X", text)
+ text = re.sub(
+ r"\b[\w.-]+\.(?:pdf|zip|png|jpe?g|md|txt|json|ya?ml|step|bin|conf|sock)\b",
+ "X",
+ text,
+ )
+ return text
+
+
+def quotation_faults(text: str, opening: str, closing: str) -> list[str]:
+ """Marks must alternate open, close, open, close — and end closed."""
+ if opening == closing:
+ count = text.count(opening)
+ return [] if count % 2 == 0 else [f"odd number of {opening} ({count})"]
+ faults, depth = [], 0
+ for index, char in enumerate(text):
+ if char == opening:
+ if depth:
+ faults.append(
+ f"{opening} opens while already open: "
+ f"...{text[max(0, index - 40) : index + 20]}..."
+ )
+ depth += 1
+ elif char == closing:
+ if not depth:
+ faults.append(
+ f"{closing} closes nothing: "
+ f"...{text[max(0, index - 40) : index + 20]}..."
+ )
+ else:
+ depth -= 1
+ if depth:
+ faults.append(f"{depth} quotation(s) never closed")
+ return faults
+
+
+def main() -> int:
+ languages = sys.argv[1:] or sorted(QUOTES)
+ worst = 0
+ for language in languages:
+ opening, closing = QUOTES[language]
+ pages = sorted(Path("docs", language).rglob("*.md"))
+ quotes = spacing = chains = 0
+ problems: list[str] = []
+ for page in pages:
+ text = prose(page.read_text(encoding="utf-8"))
+ for fault in quotation_faults(text, opening, closing):
+ quotes += 1
+ problems.append(f" {page}: {fault}")
+ rule = (
+ PLAIN_SPACE_BEFORE_PUNCT
+ if language in SPACE_REQUIRED
+ else SPACE_BEFORE_PUNCT
+ )
+ for match in rule.finditer(text):
+ spacing += 1
+ wrong = "breakable space" if language in SPACE_REQUIRED else "space"
+ problems.append(
+ f" {page}: {wrong} before '{match.group()[-1]}': "
+ f"...{text[max(0, match.start() - 40):match.end() + 10]}..."
+ )
+ allowed = language in CHAINS_ALLOWED
+ chain_rule = () if allowed else HYPHEN_CHAINS.finditer(text)
+ for match in chain_rule:
+ chains += 1
+ problems.append(
+ f" {page}: hyphen inside a product name '{match.group()}'"
+ )
+ if language in JUNCTION_FORBIDDEN:
+ for match in JUNCTION_HYPHEN.finditer(text):
+ chains += 1
+ problems.append(
+ f" {page}: junction hyphen '{match.group()}' "
+ f"— not used in this language"
+ )
+
+ marks = sum(prose(p.read_text(encoding="utf-8")).count(opening) for p in pages)
+ status = "ok" if not problems else f"{len(problems)} PROBLEMS"
+ print(
+ f"{language}: {len(pages)} pages, {marks} quotations "
+ f"({opening}…{closing}), quote faults {quotes}, spacing {spacing}, "
+ f"hyphen chains {chains} — {status}"
+ )
+ for problem in problems[:8]:
+ print(problem)
+ worst = max(worst, len(problems))
+ return 1 if worst else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/map_anchors.py b/scripts/map_anchors.py
new file mode 100644
index 0000000..7166e34
--- /dev/null
+++ b/scripts/map_anchors.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""Rewrite English anchor fragments in a translation to the translated slugs.
+
+Anchor slugs come from heading text, so a translated heading gets a different
+slug and every link pointing at it breaks — including links on pages nobody
+touched. Translators leave the English fragment in place; this maps it across.
+
+The mapping is positional: the structure comparison already proves the
+translation has the same headings in the same order, so the nth heading of the
+English page and the nth heading of the translation are the same heading. That
+is stronger than matching on text, which cannot work once the text is in another
+language.
+
+Usage: map_anchors.py [--apply]
+Without --apply it only reports what it would change.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+HEADING_ID = re.compile(r" list[str]:
+ """Heading ids of a built page, in document order.
+
+ The default language has no URL segment of its own — `docs/en/index.md` is
+ served at the site root, not under `en/` — so English pages are looked up
+ without a prefix.
+ """
+ stem = page[: -len(".md")]
+ stem = "" if stem == "index" else stem.removesuffix("/index")
+ prefix = "" if language == "en" else language
+ parts = [p for p in (prefix, stem) if p]
+ html = site.joinpath(*parts, "index.html")
+ if not html.exists():
+ raise SystemExit(
+ f"No built page for {language}/{page} at {html} — build the site first."
+ )
+ return HEADING_ID.findall(html.read_text(encoding="utf-8"))
+
+
+def target_page(link: str, page: str) -> str | None:
+ """The markdown page a link points at, relative to the docs root."""
+ path, _, _ = link.partition("#")
+ if link.startswith(("http://", "https://", "mailto:")):
+ return None
+ if not path:
+ return page
+ resolved = (Path(page).parent / path).as_posix()
+ resolved = Path(resolved).resolve().relative_to(Path.cwd().resolve()).as_posix()
+ return resolved if resolved.endswith(".md") else None
+
+
+def main() -> int:
+ site, language = Path(sys.argv[1]), sys.argv[2]
+ apply = "--apply" in sys.argv
+ docs = Path("docs")
+
+ english = {
+ p.relative_to(docs / "en").as_posix(): built_ids(
+ site, "en", p.relative_to(docs / "en").as_posix()
+ )
+ for p in (docs / "en").rglob("*.md")
+ }
+ translated = {page: built_ids(site, language, page) for page in english}
+
+ changes, unmapped = [], []
+ for page in sorted(english):
+ source = docs / language / page
+ if not source.exists():
+ continue
+ text = original = source.read_text(encoding="utf-8")
+ for link in set(LINK.findall(text)):
+ path, _, fragment = link.partition("#")
+ target = target_page(link, page)
+ if target is None or target not in english:
+ continue
+ ids_en, ids_tr = english[target], translated[target]
+ if fragment not in ids_en:
+ continue
+ if len(ids_en) != len(ids_tr):
+ unmapped.append(
+ f"{language}/{page} -> {link}: {target} has "
+ f"{len(ids_en)} headings in English, {len(ids_tr)} translated"
+ )
+ continue
+ replacement = ids_tr[ids_en.index(fragment)]
+ if replacement != fragment:
+ text = text.replace(f"]({link})", f"]({path}#{replacement})")
+ changes.append(
+ f" {language}/{page}\n {fragment} -> {replacement}"
+ )
+ if text != original:
+ if apply:
+ source.write_text(text, encoding="utf-8")
+
+ verb = "rewritten" if apply else "to rewrite"
+ print(f"{len(changes)} anchors {verb} in docs/{language}.")
+ for change in changes:
+ print(change)
+ if unmapped:
+ print(f"\n{len(unmapped)} could not be mapped — structure differs:")
+ for problem in unmapped:
+ print(f" {problem}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/stamp_translation.py b/scripts/stamp_translation.py
new file mode 100644
index 0000000..61fc83c
--- /dev/null
+++ b/scripts/stamp_translation.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""Write the translated_from stamp into a translation's frontmatter.
+
+Stamp a translation only when it has actually been (re-)translated against the
+current English source. A stamp updated without real translation work reports
+green and makes the staleness invisible — that is the one gap the status check
+cannot close.
+
+ uv run python scripts/stamp_translation.py docs/fi/user-guide/hardware.md
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+from translation_status import configured_languages
+
+DOCS = Path("docs")
+STAMP_KEY = "translated_from"
+
+
+def english_source(translation: Path, default: str) -> Path:
+ """docs// -> docs//."""
+ parts = translation.parts
+ if len(parts) < 3 or parts[0] != DOCS.name:
+ raise SystemExit(f"{translation}: not a path under docs//")
+ if parts[1] == default:
+ raise SystemExit(
+ f"{translation}: this is a source page, not a translation. "
+ f"Source pages carry no stamp — that is the point: an English edit "
+ f"needs no ceremony."
+ )
+ return DOCS / default / Path(*parts[2:])
+
+
+def blob_hash(path: Path) -> str:
+ return subprocess.run(
+ ["git", "hash-object", str(path)],
+ capture_output=True, text=True, check=True,
+ ).stdout.strip()
+
+
+def restamp(text: str, value: str) -> str:
+ """Set the stamp, replacing an existing one and preserving other keys."""
+ line = f"{STAMP_KEY}: {value}"
+ if not text.startswith("---\n"):
+ return f"---\n{line}\n---\n\n{text}"
+ end = text.find("\n---", 4)
+ if end == -1:
+ raise SystemExit("frontmatter is not terminated")
+ front, body = text[4:end], text[end + 4:].lstrip("\n")
+ kept = [l for l in front.splitlines() if not l.startswith(f"{STAMP_KEY}:")]
+ return "---\n" + "\n".join([*kept, line]) + "\n---\n\n" + body
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("translations", nargs="+", type=Path)
+ args = parser.parse_args()
+
+ default, _ = configured_languages()
+ for translation in args.translations:
+ if not translation.exists():
+ raise SystemExit(f"{translation}: does not exist")
+ source = english_source(translation, default)
+ if not source.exists():
+ raise SystemExit(f"{translation}: no English source at {source}")
+ value = blob_hash(source)
+ translation.write_text(
+ restamp(translation.read_text(encoding="utf-8"), value),
+ encoding="utf-8",
+ )
+ print(f"{translation}: {STAMP_KEY} = {value}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/translation_status.py b/scripts/translation_status.py
new file mode 100644
index 0000000..ff22ea3
--- /dev/null
+++ b/scripts/translation_status.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""Report which translations are missing or out of date.
+
+A translation records the git blob hash of the English source it was written
+against, in its own frontmatter:
+
+ ---
+ translated_from: at translation time>
+ ---
+
+The English page carries nothing, so an English edit needs no ceremony: editing
+it changes its content, which changes its hash, which makes every translation of
+it report as stale on its own.
+
+Reports; never blocks. Exit status is 0 unless the check itself could not run.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+DOCS = Path("docs")
+STAMP_KEY = "translated_from"
+
+
+class _Loader(yaml.SafeLoader):
+ """mkdocs.yml carries python/name tags that SafeLoader refuses to parse."""
+
+
+_Loader.add_multi_constructor("", lambda loader, suffix, node: None)
+
+
+def configured_languages() -> tuple[str, list[str]]:
+ """Return (default language, other languages) from the i18n plugin config."""
+ config = yaml.load(Path("mkdocs.yml").read_text(encoding="utf-8"), Loader=_Loader)
+ for plugin in config.get("plugins", []):
+ if isinstance(plugin, dict) and "i18n" in plugin:
+ languages = plugin["i18n"]["languages"]
+ default = next(l["locale"] for l in languages if l.get("default"))
+ others = [l["locale"] for l in languages if not l.get("default")]
+ return default, others
+ raise SystemExit("mkdocs.yml has no i18n plugin configuration")
+
+
+def blob_hash(path: Path) -> str:
+ return subprocess.run(
+ ["git", "hash-object", str(path)],
+ capture_output=True, text=True, check=True,
+ ).stdout.strip()
+
+
+def stamp_of(path: Path) -> str | None:
+ """Read translated_from from a page's frontmatter, if it has one."""
+ text = path.read_text(encoding="utf-8")
+ if not text.startswith("---\n"):
+ return None
+ end = text.find("\n---", 4)
+ if end == -1:
+ return None
+ front = yaml.safe_load(text[4:end]) or {}
+ value = front.get(STAMP_KEY)
+ return str(value) if value else None
+
+
+def english_diff(stamped: str, current: Path) -> str | None:
+ """Diff the stamped English blob against the English page as it stands now.
+
+ The current page is compared from the working tree rather than as a stored
+ object: `git hash-object` computes a hash without writing the object, so
+ diffing two hashes would fail on the side that was never stored.
+ """
+ blob = subprocess.run(
+ ["git", "cat-file", "-p", stamped], capture_output=True, text=True,
+ )
+ if blob.returncode != 0:
+ return None # stamped blob not in this clone — CI needs fetch-depth: 0
+ with tempfile.TemporaryDirectory() as tmp:
+ was = Path(tmp) / current.name
+ was.write_text(blob.stdout, encoding="utf-8")
+ result = subprocess.run(
+ ["git", "diff", "--no-index", "--no-color", str(was), str(current)],
+ capture_output=True, text=True,
+ )
+ # --no-index exits 1 when the files differ, which is the expected case.
+ # Drop the file headers: they carry a temporary path, and the page is
+ # already named in the surrounding report.
+ noise = ("diff --git ", "index ", "--- ", "+++ ")
+ return "\n".join(
+ line for line in result.stdout.splitlines()
+ if not line.startswith(noise)
+ )
+
+
+@dataclass
+class Entry:
+ language: str
+ page: str # path relative to the language directory
+ state: str # missing | unstamped | stale | orphaned | current
+ expected: str # blob hash the translation should record
+ diff: str | None = None
+
+
+def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]:
+ sources = sorted(p for p in (DOCS / default).rglob("*.md"))
+ entries: list[Entry] = []
+ for source in sources:
+ relative = source.relative_to(DOCS / default)
+ expected = blob_hash(source)
+ for language in languages:
+ target = DOCS / language / relative
+ if not target.exists():
+ entries.append(Entry(language, str(relative), "missing", expected))
+ continue
+ stamped = stamp_of(target)
+ if stamped is None:
+ entries.append(Entry(language, str(relative), "unstamped", expected))
+ elif stamped == expected:
+ entries.append(Entry(language, str(relative), "current", expected))
+ else:
+ diff = english_diff(stamped, source) if want_diff else None
+ entries.append(Entry(language, str(relative), "stale", expected, diff))
+
+ # A translation whose source was deleted is invisible to the loop above,
+ # because that walks the sources. It is still a page being served.
+ for language in languages:
+ root = DOCS / language
+ for translation in sorted(root.rglob("*.md")):
+ if not (DOCS / default / translation.relative_to(root)).exists():
+ entries.append(
+ Entry(language, str(translation.relative_to(root)), "orphaned", "")
+ )
+ return entries
+
+
+def render_text(entries: list[Entry]) -> str:
+ out = []
+ for language in sorted({e.language for e in entries}):
+ rows = [e for e in entries if e.language == language]
+ counts = {s: sum(1 for e in rows if e.state == s) for s in
+ ("current", "stale", "unstamped", "missing", "orphaned")}
+ out.append(f"{language}: " + " ".join(f"{k}={v}" for k, v in counts.items()))
+ for entry in rows:
+ if entry.state != "current":
+ out.append(f" {entry.state:9s} {entry.page}")
+ if entry.expected:
+ out.append(f" {STAMP_KEY}: {entry.expected}")
+ return "\n".join(out)
+
+
+def render_markdown(entries: list[Entry], only: set[str] | None) -> str:
+ shown = [e for e in entries if only is None or e.page in only]
+ out = ["## Translation status", ""]
+ for language in sorted({e.language for e in entries}):
+ rows = [e for e in entries if e.language == language]
+ counts = {s: sum(1 for e in rows if e.state == s) for s in
+ ("current", "stale", "unstamped", "missing", "orphaned")}
+ summary = ", ".join(f"{v} {k}" for k, v in counts.items() if v)
+ out.append(f"**{language}** — {summary}")
+ out.append("")
+
+ behind = [e for e in shown if e.state != "current"]
+ if not behind:
+ out.append("Every translation of the pages in scope is current.")
+ return "\n".join(out)
+
+ out += ["| Language | Page | State | Stamp to record |",
+ "|:---|:---|:---|:---|"]
+ for entry in behind:
+ out.append(f"| {entry.language} | `{entry.page}` | {entry.state} | `{entry.expected}` |")
+ out.append("")
+
+ for entry in behind:
+ if entry.diff:
+ out += [f"English changes since "
+ f"{entry.language}/{entry.page} was translated",
+ "", "```diff", entry.diff.rstrip(), "```", "", "", ""]
+ elif entry.state == "stale":
+ out.append(f"")
+ return "\n".join(out)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--format", choices=("text", "markdown"), default="text")
+ parser.add_argument("--diff", action="store_true",
+ help="include the English diff for stale pages")
+ parser.add_argument("--only-pages", nargs="*", metavar="PATH",
+ help="restrict the detail section to these docs//-relative paths")
+ args = parser.parse_args()
+
+ default, languages = configured_languages()
+ if not languages:
+ print("No translation languages configured.")
+ return 0
+
+ entries = collect(default, languages, want_diff=args.diff)
+ if args.format == "markdown":
+ only = set(args.only_pages) if args.only_pages else None
+ print(render_markdown(entries, only))
+ else:
+ print(render_text(entries))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/solutions/translation/finnish-glossary.md b/solutions/translation/finnish-glossary.md
new file mode 100644
index 0000000..e022a4d
--- /dev/null
+++ b/solutions/translation/finnish-glossary.md
@@ -0,0 +1,413 @@
+---
+title: Finnish translation glossary and style rules (HALMET)
+date: 2026-08-03
+category: translation
+module: documentation
+problem_type: reference
+component: documentation
+severity: medium
+applies_when:
+ - Translating any page from docs/en/ into Finnish under docs/fi/
+ - Reviewing a Finnish translation for consistency
+ - Adding a new term that has no established Finnish equivalent
+tags:
+ - translation
+ - i18n
+ - finnish
+ - terminology
+ - mkdocs-static-i18n
+---
+
+# Finnish translation glossary and style rules
+
+## Context
+
+The HALMET documentation is written in English under `docs/en/` and translated
+into Finnish under `docs/fi/`, using the `mkdocs-static-i18n` folder structure.
+Each language directory mirrors the same tree, so a translation keeps its
+source's path and filename: `docs/en/hardware/index.md` becomes
+`docs/fi/hardware/index.md`. Only markdown lives under `docs/fi/` — images and
+other assets stay with the English source and are shared.
+
+**This file began as a copy of the HALPI2 glossary and deliberately keeps its
+decisions**, so that two Hat Labs products do not describe the same part with
+two different Finnish words. `carrier board` → `emolevy` is Matti Airas's call
+there and stands here too. Terms below the HALMET heading are additions this
+product needed; everything above it is shared with HALPI2, and a change to a
+shared row should be made in both repositories or in neither.
+
+Translations are produced page by page, at different times, potentially by
+different people. Without a fixed terminology list the same English term drifts
+across pages — *drop cable* becomes `haarakaapeli` on one page and
+`pudotuskaapeli` on the next — and the result reads as machine output even when
+each individual sentence is correct.
+
+This file is the reference that prevents that drift. It is a living document:
+extend it when a page introduces a term that is not listed here, rather than
+inventing a one-off translation.
+
+Unlike the other files under `solutions/`, this one has no date in its filename
+because it is meant to be edited in place, not superseded.
+
+## Names that are never translated
+
+Product names, protocol names, hardware standards, and software UI strings stay
+in English. The device's own interface is in English, so translating a menu name
+would send the reader looking for something that does not exist on screen.
+
+- **Products and software:** HALMET, HALPI2, SH-ESP32, SH-RPi, SensESP, Signal
+ K, Arduino IDE, ESP-IDF, ESPHome, PlatformIO, Hat Labs
+- **Hardware and standards:** ESP32-WROOM-32E, ADS1115, NMEA 2000, CAN bus,
+ I2C, 1-Wire, GPIO, JTAG, USB, ADC, TVS, PG7, PG9, SP13, M12, Phoenix MC,
+ Schmitt trigger, IoT
+- **Pin and signal names are copied exactly:** `D1`–`D4`, `A1`–`A4`, `SDA`,
+ `SCL`, `DQ`, `TXD0`, `RXD0`, `EN`, `IO0`, `GPIO2`, `CCS`, `LP`, `VP`, `VN`,
+ `3V3`, `GND`. These are printed on the board; a translated pin name sends the
+ reader looking for a label that does not exist.
+- **UI paths, commands, hostnames, file paths:** **Networking**, **WiFi
+ (wlan0)**, **Add**, `raspi-config`, `passwd`, `shutdown`, `halos.local`,
+ `can0`, `pi`, `halos`
+
+Code blocks, command output, URLs, and image filenames are never touched.
+
+## Style rules
+
+### Units and numbers
+
+Finnish follows SI spacing and uses a decimal comma. The English source does
+not, so this requires an active conversion on nearly every technical page.
+
+| English source | Finnish |
+|:---------------|:--------|
+| `12V`, `0.9A` | `12 V`, `0,9 A` |
+| `5.5 x 2.1 mm` | `5,5 × 2,1 mm` |
+| `-20°C to +60°C` | `−20 °C … +60 °C` |
+| `1.5mm²`, `2m` | `1,5 mm²`, `2 m` |
+| `120Ω` | `120 Ω` |
+| `3-5A` | `3–5 A` (en dash for ranges) |
+
+Dimensions written as a single product spec keep the tight form:
+`200×130×60 mm`.
+
+### Product names in compounds and inflections
+
+Finnish compounds a multi-word proper name with a space and a hyphen; a
+single-word name compounds directly:
+
+- `NMEA 2000 -verkko`, `NMEA 2000 -väylä`, `Signal K -palvelin`,
+ `Raspberry Pi -antenni`, `Compute Module 5 -moduuli`
+- `HALPI2-kotelo`, `E7T-liitin`, `HaLOS-levykuva`, `USB-näppäimistö`
+
+Case endings attach with a colon when the name ends in a digit or is read as
+letters: `HALPI2:n`, `CM5:n`, `HaLOS:n`, `NMEA 2000:n`.
+
+### Address form
+
+Instructions use the **second person singular imperative** — the standard
+register for Finnish consumer and installation manuals:
+
+> Kytke virtajohto. Varmista napaisuus yleismittarilla ennen jännitteen
+> kytkemistä.
+
+Descriptive passages use the passive or a plain statement:
+
+> Laite sammuu automaattisesti, kun virransyöttö katkaistaan.
+
+Do not translate the English *you* literally into `sinä` — Finnish imperative
+already carries it, and the explicit pronoun reads as clumsy translation.
+
+### Admonitions
+
+Standard admonition titles (`Note`, `Warning`, `Tip`, `Info`) are translated
+centrally via the plugin's `admonition_translations` setting, not in the page
+source. **Custom** titles written into the page — `!!! note "Shop Link"` — are
+part of the content and must be translated: `!!! note "Linkki verkkokauppaan"`.
+
+### Images
+
+Image captions and alt texts are translated; filenames and paths are not.
+Screenshots (`raspi-config-menu.jpg`, `networking-menu.jpg`,
+`wifi-password.jpg`) show an English interface and are reused as-is. This is
+intentional and correct — the reader will see English on their own screen too.
+
+### Links
+
+Relative links and image paths are copied from the English source unchanged. The
+plugin merges the language trees, so `../user-guide/operation.md` resolves to the
+Finnish page when one exists and falls back to English when it does not, and an
+image path resolves to the single shared copy under `docs/en/`. Never add an
+`en/` or `fi/` segment to a path inside a page — the language is decided by which
+directory the file itself lives in, not by its links.
+
+### Navigation titles
+
+Section and page titles in the navigation are not part of any markdown file —
+they live in `mkdocs.yml` under the i18n plugin's `nav_translations`. That is the
+single source of truth; do not restate the full list here. Two entries are
+judgement calls worth recording:
+
+- `Errata` → **Tunnetut virheet**. The Latin term is opaque to a general reader;
+ plain Finnish is clearer for a page listing known hardware defects.
+- `FAQ` → **UKK** (*usein kysytyt kysymykset*). The established Finnish
+ abbreviation.
+
+When a new page is added to the nav in English, add its Finnish title to
+`nav_translations` in the same change — an untranslated entry silently falls
+back to English and is easy to miss.
+
+## Glossary
+
+### Enclosure, mounting, and installation
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| carrier board | emolevy | Deliberate: not literally accurate, but the term readers know. Decided by Matti Airas, 2026-08-03 |
+| enclosure | kotelo | |
+| heat sink | jäähdytyselementti | |
+| waterproof | vesitiivis | |
+| rugged | kestäväksi rakennettu | Avoid the loan word *rugged* |
+| wall-mount | seinäkiinnitys | |
+| mounting surface | kiinnitysalusta | |
+| pilot hole | esiporausreikä | |
+| mounting template | porausmalline | Drill template |
+| clearance | vapaa tila | |
+| bilge | pilssi | |
+| bulkhead | laipio | |
+| cable gland | läpivientiholkki | PG7 cable gland → `PG7-läpivientiholkki` |
+| cable routing | kaapelireititys | |
+| service loop | johtolenkki | Slack left at both cable ends |
+| chafing | hankautuminen | |
+| cable tie | nippuside | |
+
+**A note on `emolevy`.** The term was chosen for reader familiarity over literal
+accuracy, and it carries one risk: *emolevy* normally means a motherboard, which
+would imply the board is the computer and the CM5 an add-on — the reverse of how
+HALPI2 is built. When translating passages where that relationship matters
+(reseating the CM5, troubleshooting a board that will not boot), make the roles
+explicit in the surrounding sentence rather than relying on the term to carry
+them.
+
+### Electrical
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| power supply | virtalähde | |
+| power source | virransyöttö | |
+| input voltage range | syöttöjännitealue | |
+| polarity | napaisuus | |
+| positive (+) / negative (−) | plus (+) / miinus (−) | |
+| fuse | sulake | |
+| inline fuse | linjasulake | |
+| circuit breaker | johdonsuojakatkaisija | Electrical panel breaker |
+| current limiting | virranrajoitus | |
+| overcurrent | ylivirta | |
+| voltage drop | jännitehäviö | |
+| grounding | maadoitus | |
+| short circuit | oikosulku | |
+| wire gauge | johtimen poikkipinta-ala | Finnish uses mm², not AWG |
+| marine-grade wire | merikäyttöön hyväksytty johdin | |
+| strip (a wire) | kuoria | |
+| wire strippers | kuorintapihdit | |
+| crimping | puristusliitos | Verb: *puristaa liitin kiinni* |
+| crimper | puristuspihdit | |
+| heat-shrink tubing | kutistesukka | |
+| heat gun | kuumailmapuhallin | |
+| multimeter | yleismittari | |
+| continuity test | jatkuvuusmittaus | |
+| terminal | liitin | |
+| terminal block | riviliitin | |
+| strain relief | vedonpoisto | |
+| super-capacitor | superkondensaattori | |
+| real-time clock | reaaliaikakello | |
+| backup battery | varaparisto | |
+
+### Connectors and interfaces
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| connector | liitin | |
+| barrel connector | DC-pyöröliitin | Add *(barrel)* on first mention |
+| header (GPIO, button) | liitin | `40-nastainen GPIO-liitin` |
+| pin | nasta | |
+| backbone | runkokaapeli | NMEA 2000 backbone |
+| drop cable | haarakaapeli | |
+| T-connector / T-adapter | T-liitin | |
+| termination (120 Ω) | päätevastus | The component; the act is *terminointi* |
+| front panel | etupaneeli | |
+| antenna | antenni | |
+| extension cable | jatkokaapeli | |
+| male / female | uros / naaras | Connector gender |
+
+### System behaviour and status
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| boat computer | venetietokone | |
+| boot / to boot | käynnistyä | |
+| first boot | ensikäynnistys | |
+| shutdown | sammutus | |
+| graceful shutdown | hallittu sammutus | |
+| power loss | jännitteen menetys | |
+| blackout | sähkökatko | |
+| glitch immunity | häiriönsieto | |
+| power management | virranhallinta | |
+| status LED | tila-LED | |
+| LED bar | LED-rivi | |
+| monitoring | valvonta | |
+| passive cooling | passiivinen jäähdytys | |
+| filesystem | tiedostojärjestelmä | |
+| unmount (filesystem) | irrottaa | *tiedostojärjestelmä irrotetaan turvallisesti* |
+| reseat (a module) | asettaa uudelleen paikalleen | |
+
+### Software and networking
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| firmware | firmware | Not *laiteohjelmisto* — Hat Labs convention |
+| daemon | daemon | Not *taustaprosessi* — Hat Labs convention |
+| to flash | flashata | Established Hat Labs usage |
+| operating system image | levykuva | |
+| headless | ilman näyttöä | First mention: `ilman näyttöä (headless)` |
+| deployment | käyttöönotto | |
+| container app | konttisovellus | |
+| container image | konttikuva | Not *levykuva* — that is a disk image |
+| dashboard | koontinäyttö | Homarr's *dashboard* view |
+| WiFi Access Point | WiFi-tukiasema | |
+| wired / wireless | langallinen / langaton | |
+| credentials | tunnukset | |
+| username / password | käyttäjätunnus / salasana | |
+| default password | oletussalasana | |
+| single sign-on (SSO) | kertakirjautuminen (SSO) | |
+| Certificate Authority (CA) | varmenteen myöntäjä (CA) | |
+| to trust (a certificate) | luottaa | |
+| web interface | verkkokäyttöliittymä | |
+| browser | selain | |
+| system administration | järjestelmänhallinta | |
+
+### Applications and use cases
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| chart plotter | karttaplotteri | |
+| data logging | tiedonkeruu | |
+| vessel | alus | |
+| engine parameters | moottorin mittaustiedot | |
+| fleet management | kalustonhallinta | |
+| predictive maintenance | ennakoiva kunnossapito | |
+| process monitoring | prosessivalvonta | |
+| remote monitoring | etävalvonta | |
+| electromagnetic interference (EMI/RFI) | sähkömagneettiset häiriöt (EMI/RFI) | |
+| compliance | vaatimustenmukaisuus | |
+| warranty | takuu | |
+
+## HALMET terms
+
+HALMET is a sensor interface board, so it needs vocabulary HALPI2 never used:
+input circuits, measurement, and the things printed on a small PCB. Rows above
+this heading are shared with HALPI2 and should not be changed here alone.
+
+### Board and inputs
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| development board | kehityskortti | HALMET is sold as one; not *emolevy*, which is HALPI2's carrier board |
+| digital input | digitaalitulo | `D1`–`D4` stay as printed |
+| analog input | analogiatulo | `A1`–`A4` stay as printed |
+| input | tulo | Not *sisääntulo* in this sense |
+| output | lähtö | |
+| sender | anturi | The marine sender that a gauge reads; *lähetin* would suggest radio |
+| tank sender | tankkianturi | |
+| resistive sender | vastusanturi | |
+| gauge (engine panel gauge) | mittari | `moottoripaneelin mittari` |
+| counter | laskuri | |
+| chain counter | ketjulaskuri | |
+| alarm signal | hälytyssignaali | |
+| engine RPM | moottorin kierrosluku | Not *RPM*; the abbreviation is not used in Finnish prose |
+| tachometer | kierroslukumittari | |
+| alternator W terminal | laturin W-napa | |
+| fuel flow | polttoaineen virtaus | |
+
+### Measurement and circuits
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| galvanic isolation | galvaaninen erotus | |
+| isolated (section, area) | erotettu | `erotettu alue`, `erotettu osa` |
+| digital isolator | digitaalinen erotin | |
+| isolation barrier | erotusraja | |
+| ground loop | maasilmukka | |
+| analog-to-digital converter (ADC) | AD-muunnin | The abbreviation `ADS1115` stays |
+| resolution (16-bit) | erotuskyky | `16-bittinen erotuskyky` |
+| sampling rate | näytteenottotaajuus | |
+| low-pass filter | alipäästösuodin | |
+| cutoff frequency | rajataajuus | |
+| noise (electrical) | häiriö | Not *melu*, which is sound |
+| noise immunity | häiriönsieto | |
+| voltage divider | jännitteenjakaja | |
+| constant current source (CCS) | vakiovirtalähde | The header label `CCS` stays as printed |
+| excitation voltage | herätejännite | |
+| passive voltage measurement | passiivinen jännitemittaus | |
+| active resistance measurement | aktiivinen vastusmittaus | |
+| pull-up resistor | ylösvetovastus | |
+| pull-down resistor | alasvetovastus | |
+| threshold voltage | kynnysjännite | |
+| hysteresis | hystereesi | |
+| floating (input) | kelluva | `tulo jää kelluvaksi` |
+| normally open / normally closed | avautuva / sulkeutuva | Standard Finnish switch terms |
+| self-resetting fuse | itsestään palautuva sulake | |
+| reverse polarity protection | napaisuussuojaus | |
+| overvoltage protection | ylijännitesuojaus | |
+| switching power supply | hakkuriteholähde | |
+| current consumption | virrankulutus | |
+| short circuit | oikosulku | |
+| chafing (of a wire) | hankautuminen | |
+
+### Board features and assembly
+
+| English | Finnish | Note |
+|:--------|:--------|:-----|
+| jumper | hyppy | `hyppy` on a pin pair; see *solder jumper* for the PCB kind |
+| jumper header | hyppyliitin | The pin pair a jumper is placed on |
+| solder jumper | juotossilta | Closed with solder, not with a removable jumper — the distinction matters because the reader needs a soldering iron for one and not the other |
+| to short (a jumper) | oikosulkea | `oikosulje nastat` |
+| pad (solder pad) | juotospiste | |
+| unpopulated | kalustamaton | `kalustamattomat juotospisteet` |
+| pitch (2.54 mm) | nastaväli | `2,54 mm:n nastaväli` |
+| pluggable terminal block | irrotettava riviliitin | Phoenix MC type; `riviliitin` alone is the shared HALPI2 term |
+| silkscreen | silkkipaino | |
+| to solder | juottaa | |
+| soldering iron | juotin | |
+| grommet | läpivientikumi | Rubber or silicone; distinct from `läpivientiholkki`, the threaded gland |
+| step drill bit | porrasterä | The one that looks like a metal Christmas tree |
+| conical drill bit | kartioterä | |
+| panel connector | paneeliliitin | |
+| reset button | reset-painike | The board's own labels `Reset` and `Boot` stay in English |
+| boot button | boot-painike | |
+| bootloader | käynnistyslataaja | |
+| download mode | latauslataustila | ESP32 flashing mode |
+| user-programmable LED | käyttäjän ohjattava LED | |
+| open hardware | avoin laitteisto | |
+
+### A note on `liitin`
+
+The shared glossary renders both `connector` and `header` as `liitin`, and that
+is kept. HALMET puts the two side by side more often than HALPI2 does — *1-Wire
+header connector*, *analog input connectors* — so let the qualifier carry the
+distinction (`1-Wire-liitin`, `analogiatulojen liittimet`) rather than inventing
+a second word. Where a sentence would otherwise be ambiguous, say what the thing
+is: `piirilevyn nastarima` for a bare pin strip, `kaapeliliitin` for the plug.
+
+## Verification
+
+A translated page is not done until:
+
+1. `uv run mkdocs build --strict` passes — the same command CI runs.
+2. `uv run mkdocs serve` shows the page rendering correctly in the browser, with
+ lists as lists (see `../best-practices/markdown-lists-need-blank-line-2026-05-16.md`
+ — the blank-line rule applies identically to Finnish pages).
+3. Every term used on the page that appears in this glossary matches it.
+
+## Related
+
+- `solutions/best-practices/markdown-lists-need-blank-line-2026-05-16.md`
+- mkdocs-static-i18n documentation: https://ultrabug.github.io/mkdocs-static-i18n/
diff --git a/uv.lock b/uv.lock
index de94719..ea4246b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -163,14 +163,14 @@ source = { virtual = "." }
dependencies = [
{ name = "click" },
{ name = "mkdocs-material" },
- { name = "mkdocs-print-site-plugin" },
+ { name = "mkdocs-static-i18n" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = "<8.3" },
{ name = "mkdocs-material", specifier = ">=9.5" },
- { name = "mkdocs-print-site-plugin", specifier = ">=2.8" },
+ { name = "mkdocs-static-i18n", specifier = ">=1.2" },
]
[[package]]
@@ -356,15 +356,15 @@ wheels = [
]
[[package]]
-name = "mkdocs-print-site-plugin"
-version = "2.8"
+name = "mkdocs-static-i18n"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "mkdocs-material" },
+ { name = "mkdocs" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/18/5c71f48b83191fb30cc58617fea20f56647eaa6cafd06a7fb34c738c5acb/mkdocs_print_site_plugin-2.8.tar.gz", hash = "sha256:ab1c89cdb468352975e3bb3bb0ef25dcc2bb88931b03f173206dc95ab02f843f", size = 231688, upload-time = "2025-08-03T14:15:07.579Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f9/51e2ffda9c7210bc35a24f3717b08c052cd4b728dfa87f901c00d8005259/mkdocs_static_i18n-1.3.1.tar.gz", hash = "sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf", size = 1371325, upload-time = "2026-02-20T10:42:41.835Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/3e/7513f2f37c563da65d1b91781e047f4a1c0ceac8206d4f6042428428e4ad/mkdocs_print_site_plugin-2.8-py3-none-any.whl", hash = "sha256:838bd0a9b7141c11c0f1fdaa51ffe70c35740bec1f07c0806f8018e92f93f9da", size = 21477, upload-time = "2025-08-03T14:15:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" },
]
[[package]]