Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/setup/tailscale-qr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Show this QR code on your phone — RoamCore Tailscale wizard

## §1 What this is

RoamCore draws a small square picture on your dashboard that you scan with the Tailscale app on your phone, so you don't have to type a long secret code by hand.

## §2 What you see

When RoamCore is ready for you to connect, you'll see a small black-and-white square on your dashboard under the Tailscale card, with a one-line message above it that says **"Show this to your phone"**. Below the square is a short web address — that's your one-tap backup if your phone can't read the square for any reason.

## §3 What you do

1. Open the Tailscale app on your phone. (You can get it from your phone's app store — search for "Tailscale".)
2. On your van's dashboard, find the Tailscale card. You'll see the small black-and-white square.
3. Tap the in-app camera button in Tailscale and point your phone at the square on your dashboard screen. Tailscale reads the square in about a second.
4. The Tailscale app asks "do you want to connect?" — tap **Approve**.
5. RoamCore's status line changes from **"Show this to your phone"** to **"Already paired"**. You're done — your van is now reachable from your phone wherever you have signal.

If you ever change the secret code, tap **Regenerate** on the dashboard card before you scan again — the square updates and the address below it changes too.

## §4 What to do if it goes wrong

- **"Your phone can't see this QR code — copy this URL instead"** — open the web address shown below the square in any browser on your phone. The Tailscale app picks up the connection automatically.
- **"Waiting for Tailscale account"** — RoamCore doesn't have your secret code yet. Open the Tailscale card and check that the long code field is filled in.
- **"Already paired" but you want to start over** — tap **Regenerate** on the dashboard card, then scan the new square with your phone.
- The square is just a friendly shortcut — if it ever looks broken, blurry, or missing, the web address below it does the same job in a more old-fashioned way.

## §5 Useful links

- For the integration developer / advanced user — see `homeassistant/packages/roamcore_tailscale_qr.yaml` for the raw helper definitions, `homeassistant/packages/scripts/qr_generator.py` for the small generator that draws the square (no extra software needed), and `homeassistant/packages/tests/test_tailscale_qr.py` for the tests that guard the contract.
- The QR code uses a "tailscale login" web address of the form `https://login.tailscale.com/a/<your-secret-key>`. Your phone's Tailscale app reads this and connects you automatically.
- If you want to understand the wizard as a whole (not just the QR step), see [Guided remote access setup](guided-remote-access.md).
24 changes: 24 additions & 0 deletions homeassistant/packages/roamcore_setup_wizard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,27 @@ automation:
data:
title: "Remote access done"
message: "Remote access is set up — moving on to the next setup step."

# Wave 9 #122.d.ii — Phase 6 Tailscale QR tile. When the operator
# transitions into the `tailscale_paste_key` stage the wizard
# needs the device-key for the QR generator. We copy the value
# from `input_text.rc_tailscale_auth_key` (in
# `roamcore_remote_access_setup.yaml`) into the QR-package helper
# `input_text.rc_tailscale_qr_device_key` on the same stage
# transition. One automation, idempotent across re-fires.
- id: rc_setup_register_tailscale_qr_device_key
alias: "RC: Copy Tailscale auth key into the QR-package device key"
mode: single
trigger:
- platform: state
entity_id: input_select.rc_remote_access_setup_stage
to: tailscale_paste_key
condition:
- condition: template
value_template: "{{ states('input_text.rc_tailscale_auth_key') | trim != '' }}"
action:
- service: input_text.set_value
target:
entity_id: input_text.rc_tailscale_qr_device_key
data:
value: "{{ states('input_text.rc_tailscale_auth_key') | trim }}"
167 changes: 167 additions & 0 deletions homeassistant/packages/roamcore_tailscale_qr.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# RoamCore Tailscale Wizard — QR Code for Mobile Pairing (Phase 6)
#
# Wave 9 #122.d.ii — Tier-b recipe over a self-contained Python
# QR generator (`homeassistant/packages/scripts/qr_generator.py`,
# pure stdlib, no `qrcode` / `segno` / `qrencode` dep). The QR encodes
# the `https://login.tailscale.com/a/<device_key>` URL that the
# operator scans with the Tailscale app on their phone instead of
# copy-pasting the long auth key.
#
# Doctrine (Bernard, 2026-08-04 — chat #7563-7574):
# - must not fail + super intuitive + critical infrastructure
#
# Apply:
# - Verification is mandatory: the QR payload must parse as a valid
# `tailscale login` URL; the rendered SVG must have the right
# viewBox + dark modules; the wizard surfaces the QR tile only
# when stage == ts_paste_key OR stage == ts_done.
# - Auto-recover: if QR generation fails (no `qrcode` dep / no
# display) we fall back to a plain-English copy-paste block
# ("Open this URL on your phone: https://login.tailscale.com/a/abc")
# instead of leaving a blank tile.
# - Plain-English errors: "Your phone can't see this QR code — open
# this URL instead", NOT a raw Python stack trace.
# - Idempotent installers: re-pushing the package does NOT duplicate
# the QR tile (unique_id is mandatory on every template sensor +
# button + binary sensor).
# - Tier discipline: tier-b recipe; the QR generator is pure
# stdlib, no external service. We DO NOT operate our own VPN
# control plane (the directive §"Phase 6 delivery" is explicit
# on that).
#
# STATE OWNERSHIP CONTRACT — what changes when this slice lands:
# - 4 input_text helpers:
# rc_tailscale_qr_device_key (mode: password — auto-populated
# from rc_tailscale_auth_key via the setup wizard helper).
# rc_tailscale_qr_login_url (the full Tailscale login URL).
# rc_tailscale_qr_svg_path (where the SVG was last written).
# rc_tailscale_qr_nonce (visual re-scan marker).
# - 1 input_button:
# button.rc_tailscale_qr_regenerate (rebuilds the QR).
# - 1 binary_sensor:
# binary_sensor.rc_tailscale_qr_visible
# true when stage == tailscale_paste_key OR tailscale_done
# AND device_key is non-empty.
# - 1 template sensor:
# sensor.rc_tailscale_qr_status (plain-English status string).
# - 1 shell_command:
# shell_command.rc_tailscale_qr_render (invokes qr_generator.py).
# - 2 §8 MANDATORY AUTOMATIONS:
# §8.Q.1 qr_compute_login_url (build + persist the URL).
# §8.Q.2 qr_regenerate_on_request (button press → rebuild).

input_text:
rc_tailscale_qr_device_key:
name: "RC Tailscale QR: Device Key"
initial: ""
mode: password

rc_tailscale_qr_login_url:
name: "RC Tailscale QR: Login URL (operator-visible, contains the device key)"
initial: ""

rc_tailscale_qr_svg_path:
name: "RC Tailscale QR: SVG Path (file on disk)"
initial: "/config/www/roamcore/qr/qr-current.svg"

rc_tailscale_qr_nonce:
name: "RC Tailscale QR: Nonce (visual re-scan marker)"
initial: "1"

input_button:
rc_tailscale_qr_regenerate:
name: "RC Tailscale QR: Regenerate"
icon: mdi:refresh

template:
- binary_sensor:
- name: "RC Tailscale QR: Visible"
unique_id: rc_tailscale_qr_visible
icon: mdi:qrcode-scan
state: >-
{% set key = states('input_text.rc_tailscale_qr_device_key') | trim %}
{% set stage = states('input_select.rc_remote_access_setup_stage') %}
{{ key != '' and stage in ['tailscale_paste_key', 'tailscale_done'] }}

- sensor:
- name: "RC Tailscale QR: Status"
unique_id: rc_tailscale_qr_status
icon: mdi:qrcode
state: >-
{% set key = states('input_text.rc_tailscale_qr_device_key') | trim %}
{% set url = states('input_text.rc_tailscale_qr_login_url') | trim %}
{% set stage = states('input_select.rc_remote_access_setup_stage') %}
{% if key == '' %}
Waiting for Tailscale account.
{% elif url == '' %}
Show this to your phone.
{% elif stage == 'tailscale_done' %}
Already paired.
{% elif stage == 'recovery' %}
QR unavailable — use the URL below.
{% else %}
Show this to your phone.
{% endif %}
attributes:
login_url: "{{ states('input_text.rc_tailscale_qr_login_url') }}"
svg_path: "{{ states('input_text.rc_tailscale_qr_svg_path') }}"
nonce: "{{ states('input_text.rc_tailscale_qr_nonce') }}"
stage: "{{ states('input_select.rc_remote_access_setup_stage') }}"

automation:
# §8.Q.1 — On wizard stage transition INTO tailscale_paste_key OR
# tailscale_done, build the URL `https://login.tailscale.com/a/<key>`
# and persist it into rc_tailscale_qr_login_url so the dashboard
# tile + copy-paste fallback can always show the URL.
- id: rc_tailscale_qr_compute_login_url
alias: "RC: Tailscale QR — Compute login URL on stage transition"
mode: single
trigger:
- platform: state
entity_id: input_select.rc_remote_access_setup_stage
to: tailscale_paste_key
- platform: state
entity_id: input_select.rc_remote_access_setup_stage
to: tailscale_done
- platform: state
entity_id: input_text.rc_tailscale_qr_device_key
condition:
- condition: template
value_template: "{{ states('input_text.rc_tailscale_qr_device_key') | trim != '' }}"
- condition: template
value_template: "{{ states('input_select.rc_remote_access_setup_stage') in ['tailscale_paste_key', 'tailscale_done'] }}"
action:
- service: input_text.set_value
target:
entity_id: input_text.rc_tailscale_qr_login_url
data:
value: "https://login.tailscale.com/a/{{ states('input_text.rc_tailscale_qr_device_key') | trim }}"

# §8.Q.2 — On button press, append a fresh nonce + invoke the QR
# generator (idempotent across presses; nonce increments by 1).
- id: rc_tailscale_qr_regenerate_on_request
alias: "RC: Tailscale QR — Regenerate on button press"
mode: single
trigger:
- platform: state
entity_id: input_button.rc_tailscale_qr_regenerate
condition:
- condition: template
value_template: "{{ states('input_text.rc_tailscale_qr_login_url') | trim != '' }}"
action:
- service: input_text.set_value
target:
entity_id: input_text.rc_tailscale_qr_nonce
data:
value: "{{ (states('input_text.rc_tailscale_qr_nonce') | int(0)) + 1 }}"
- service: shell_command.rc_tailscale_qr_render
data:
url: "{{ states('input_text.rc_tailscale_qr_login_url') }}"
out_path: "{{ states('input_text.rc_tailscale_qr_svg_path') }}"

shell_command:
rc_tailscale_qr_render: >-
python3 /config/packages/scripts/qr_generator.py "{{ url | trim }}"
256 "{{ out_path | trim }}"
&& echo "OK qr rendered to {{ out_path | trim }}"
|| echo "FAIL qr generation — fallback to copy-paste URL"
Loading
Loading