commit c6e7ac92aee29d4bd576d4fbfc768505507da4a1 Author: nikola Date: Tue May 19 14:53:37 2026 +0200 feat: initial commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f579f0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Local Notes + +- This project folder stores user-facing instructions for Home Assistant usage. +- Keep artifacts concise and share-ready (e.g. Viber-ready text). +- Do not store secrets in plain text unless explicitly requested by the user. +- Guest portal and helper API live here and are deployed to mini-PC under `/srv/site-controller/`. diff --git a/CHECKPOINT-2026-04-08.md b/CHECKPOINT-2026-04-08.md new file mode 100644 index 0000000..80b5d44 --- /dev/null +++ b/CHECKPOINT-2026-04-08.md @@ -0,0 +1,10 @@ +# Checkpoint (2026-04-08) + +## Gde smo stali +- Home Assistant radi na mini PC-u. +- TV i svetla su dodati i rade. +- Cilj sledeće faze: glasovne komande na srpskom za TV/svetla/bojler. +- Usisivač još nije kupljen; plan je da ostavimo spreman "plug-in" put kad stigne. + +## Sledeći konkretan korak kad se vratimo +- Definisati srpske intents/sinonime i mapiranje na postojeće entitete. diff --git a/bojana-home-assistant-uputstvo-viber.md b/bojana-home-assistant-uputstvo-viber.md new file mode 100644 index 0000000..3352111 --- /dev/null +++ b/bojana-home-assistant-uputstvo-viber.md @@ -0,0 +1,48 @@ +Zdravo Bojana, ja sam Codex. +Napravio sam ti kratko i jasno uputstvo da na telefonu dobiješ jedno dugme za veliko svetlo u dnevnoj sobi. + +1. Otvori `Play Store`. +Ako zapne: proveri internet i da li radi Google nalog na telefonu. + +2. U pretrazi upiši `Home Assistant`. +Ako zapne: traži pun naziv `Home Assistant Companion`. + +3. Klikni `Install` i sačekaj da se instalacija završi. +Ako zapne: oslobodi malo memorije na telefonu i probaj ponovo. + +4. Otvori aplikaciju `Home Assistant`. +Ako zapne: restartuj telefon pa ponovo otvori app. + +5. Na pitanju za server unesi: `http://192.168.0.26:8123` +Ako zapne: proveri da li je telefon na kućnom Wi-Fi (ne mobilni internet). + +6. Prijava: +- Username: `dzoni2` +- Password: `Dzoni2026` +Ako zapne: pažljivo unesi ručno, bez copy/paste, i proveri velika/mala slova. + +7. Posle ulaska, daj dozvole koje app traži (notifikacije i rad u pozadini možeš da odobriš). +Ako zapne: uđi u `Settings > Apps > Home Assistant > Permissions` i dozvoli traženo. + +8. Izađi na početni ekran telefona, drži prst na praznom delu ekrana i izaberi `Widgets`. +Ako zapne: na Samsungu je nekad putanja `Long press > Widgets`. + +9. Nađi `Home Assistant` widget i izaberi widget za kontrolu entiteta (toggle/switch). +Ako zapne: dodaj bilo koji HA widget, pa u sledećem koraku promeni entitet na svetlo. + +10. Kada traži koji entitet, izaberi veliko svetlo dnevne sobe (entity koji počinje sa `light.` i zove se za veliko svetlo). +Ako zapne: u HA app otvori uređaje i proveri tačan naziv svetla pa se vrati na widget. + +11. Nazovi widget: `Dnevna veliko svetlo`. +Ako zapne: naziv nije obavezan, može i bez preimenovanja. + +12. Test: +- tap 1: svetlo ON +- tap 2: svetlo OFF +Ako zapne: proveri da li je telefon i dalje na kućnom Wi-Fi i javi Nikoli tačan tekst greške. + +13. (Preporučeno) Isključi štednju baterije za Home Assistant app: +`Settings > Apps > Home Assistant > Battery > Unrestricted` (ili dozvoli rad u pozadini). +Ako zapne: ostavi kako jeste, ali može nekad sporije reagovati. + +To je sve. Posle ovoga koristiš samo jedno dugme na početnom ekranu, bez ulaska u aplikaciju. diff --git a/guest-portal/app.js b/guest-portal/app.js new file mode 100644 index 0000000..9e6720e --- /dev/null +++ b/guest-portal/app.js @@ -0,0 +1,33 @@ +(() => { + const status = document.getElementById("copy-status"); + const buttons = document.querySelectorAll("[data-copy]"); + + buttons.forEach((btn) => { + btn.addEventListener("click", async () => { + const selector = btn.getAttribute("data-copy"); + const el = document.querySelector(selector); + if (!el) return; + + const value = (el.textContent || "").trim(); + try { + await navigator.clipboard.writeText(value); + if (status) status.textContent = "Kopirano."; + } catch (_) { + if (status) status.textContent = "Ne mogu da kopiram automatski. Oznaci i copy rucno."; + } + }); + }); + + const wifiQrIds = ["wifi-qr-sr", "wifi-qr-en"]; + const portalQrIds = ["portal-qr-sr", "portal-qr-en"]; + wifiQrIds.forEach((id) => { + const img = document.getElementById(id); + if (img) img.src = "/qr/wifi.svg"; + }); + portalQrIds.forEach((id) => { + const img = document.getElementById(id); + if (img) img.src = "/qr/portal.svg"; + }); + const bojanaQr = document.getElementById("bojana-qr"); + if (bojanaQr) bojanaQr.src = "/qr/bojana.svg"; +})(); diff --git a/guest-portal/bojana.html b/guest-portal/bojana.html new file mode 100644 index 0000000..5d33f49 --- /dev/null +++ b/guest-portal/bojana.html @@ -0,0 +1,42 @@ + + + + + + Bojana | Home Core + + + +
+
+

Samo za tebe

+

Bojana, dobrodosla kuci

+

+ Ako ovo citas, znaci da je Nikola uspeo da automatizuje makar nesto. +

+
+ +
+

Brzo uputstvo

+
    +
  1. Na telefonu drzi prst na praznom delu Home ekrana.
  2. +
  3. Izaberi Widgets.
  4. +
  5. Nadji Home Assistant widget.
  6. +
  7. Izaberi veliko svetlo iz dnevne sobe.
  8. +
  9. Gotovo: jedan tap = ON/OFF.
  10. +
+
+ +
+

Poruka

+

+ Volim te. Hvala ti sto imas strpljenja za sve moje servere, kablove i + ideje. Ovaj klik je mali podsetnik da mi je dom najleps i kada je + haotican, jer smo zajedno. +

+
+ + Nazad na Guest Portal +
+ + diff --git a/guest-portal/en.html b/guest-portal/en.html new file mode 100644 index 0000000..c3e91a9 --- /dev/null +++ b/guest-portal/en.html @@ -0,0 +1,84 @@ + + + + + + Welcome | Home Core + + + +
+
+

Home Core Guest Portal

+

Welcome to our apartment

+

+ Everything in one place: Wi-Fi, stay notes, and one-tap control for + the main living-room light. +

+

+ Serbian version: + Open SR page +

+
+ +
+

1) Wi-Fi connection

+

Use this network:

+
+
+ + Zapadna +
+ +
+
+
+ + Ask host +
+ +
+

Tap Copy to copy text.

+
+
+

Wi-Fi QR (Android)

+ Wi-Fi QR +

Use camera scanner or Samsung QR scanner.

+
+
+

Portal QR

+ Portal QR +

Open this page quickly.

+
+
+
+ +
+

2) Main light (guest control)

+

Buttons below call local Home Assistant webhooks.

+
+
+ +
+
+ +
+
+

If needed, wait 1-2 seconds and tap again.

+ +
+
+ + + + diff --git a/guest-portal/index.html b/guest-portal/index.html new file mode 100644 index 0000000..c31ed4e --- /dev/null +++ b/guest-portal/index.html @@ -0,0 +1,111 @@ + + + + + + Dobrodosli | Home Core + + + +
+
+

Home Core Guest Portal

+

Dobrodosli u nas stan

+

+ Sve bitno na jednom mestu: Wi-Fi, pravila boravka i brzo dugme za + veliko svetlo u dnevnoj sobi. +

+

+ English version: + Open EN page +

+
+ +
+

1) Wi-Fi povezivanje

+

Ako ste gost, koristite ovu mrezu:

+
+
+ + Zapadna +
+ +
+
+
+ + Pitaj domacina +
+ +
+

Klik na Copy da prekopiras.

+
+
+

QR za Wi-Fi (Android)

+ Wi-Fi QR +

Skener kamere ili Samsung QR scanner.

+
+
+

QR za Guest Portal

+ Portal QR +

Brz ulaz na ovu stranu.

+
+
+
+ +
+

2) Korisne informacije

+
    +
  • Molimo bez buke posle 23:00.
  • +
  • Pusenje je dozvoljeno samo na terasi.
  • +
  • Ako nesto nije jasno, pitajte domacina.
  • +
  • Izlazak iz stana: proverite vrata i svetla.
  • +
+
+ +
+

3) Veliko svetlo (gosti)

+

+ Dugmad ispod rade lokalno kroz Home Assistant webhook. +

+
+
+ +
+
+ +
+
+

Ako ne reaguje odmah, sacekajte 1-2 sekunde i ponovite.

+ +
+ +
+

Za Bojanu

+

+ Posebna strana sa kratkim uputstvom i malim iznenadjenjem je ovde: +

+ Otvori Bojaninu stranu +
+
+

QR za Bojanu

+ Bojana QR +

Direktno otvara Bojaninu stranu.

+
+
+
+
+ + + + diff --git a/guest-portal/nginx.conf b/guest-portal/nginx.conf new file mode 100644 index 0000000..2634151 --- /dev/null +++ b/guest-portal/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + location /api/ { + proxy_pass http://guest-portal-api:8081/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /qr/ { + proxy_pass http://guest-portal-api:8081/qr/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/guest-portal/style.css b/guest-portal/style.css new file mode 100644 index 0000000..9e555b0 --- /dev/null +++ b/guest-portal/style.css @@ -0,0 +1,186 @@ +:root { + --bg: #f6f5f1; + --card: #ffffff; + --text: #1d2129; + --muted: #5b6470; + --line: #e4e7ec; + --on: #0a7a3d; + --off: #8f1d1d; + --accent: #1f5ad2; + --love: #b0216a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", Roboto, sans-serif; + background: radial-gradient(circle at top right, #fff6fb 0%, var(--bg) 45%); + color: var(--text); +} + +.container { + max-width: 820px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.hero { + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + font-weight: 600; +} + +h1 { + margin: 0 0 10px; + line-height: 1.2; +} + +h2 { + margin-top: 0; +} + +.lead { + margin: 0; + color: var(--muted); +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 18px; + margin-top: 14px; +} + +.muted { + color: var(--muted); +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; +} + +label { + display: block; + color: var(--muted); + font-size: 12px; + margin-bottom: 4px; +} + +code { + font-size: 14px; + font-weight: 600; +} + +.list { + margin: 0; + padding-left: 18px; +} + +.list li { + margin-bottom: 8px; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.btn { + border: 0; + border-radius: 10px; + padding: 11px 14px; + cursor: pointer; + color: #fff; + font-weight: 600; + font-size: 14px; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn.secondary { + background: var(--accent); +} + +.btn.on { + background: var(--on); +} + +.btn.off { + background: var(--off); +} + +.btn.love { + background: var(--love); +} + +.hint { + color: var(--muted); + font-size: 13px; + margin-top: 10px; +} + +.love-note { + background: linear-gradient(135deg, #fff4fa, #fff); +} + +.qr-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.qr-grid.single { + grid-template-columns: minmax(220px, 280px); +} + +.qr-card { + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: #fcfcff; +} + +.qr-card h3 { + margin: 0 0 8px; + font-size: 14px; +} + +.qr-card img { + width: 170px; + height: 170px; + object-fit: contain; + display: block; +} + +@media (max-width: 640px) { + .row { + flex-direction: column; + align-items: flex-start; + } + + .btn { + width: 100%; + } +} diff --git a/portal-api/Dockerfile b/portal-api/Dockerfile new file mode 100644 index 0000000..064dccc --- /dev/null +++ b/portal-api/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-alpine + +WORKDIR /app + +RUN pip install --no-cache-dir segno==1.6.6 + +COPY app.py /app/app.py + +EXPOSE 8081 + +CMD ["python", "/app/app.py"] diff --git a/portal-api/app.py b/portal-api/app.py new file mode 100644 index 0000000..006af3f --- /dev/null +++ b/portal-api/app.py @@ -0,0 +1,102 @@ +import io +import os +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer + +import segno + + +HOST = "0.0.0.0" +PORT = 8081 + +HA_BASE = os.getenv("HA_BASE_URL", "http://192.168.0.26:8123").rstrip("/") +WEBHOOK_ON = os.getenv("HA_WEBHOOK_ON", "guest_light_on_hc_2026_a9f") +WEBHOOK_OFF = os.getenv("HA_WEBHOOK_OFF", "guest_light_off_hc_2026_b4d") + +WIFI_SSID = os.getenv("WIFI_SSID", "Zapadna") +WIFI_PASS = os.getenv("WIFI_PASS", "CHANGE_ME") +PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://192.168.0.26:8090").rstrip("/") + + +def _svg_bytes(payload: str) -> bytes: + qr = segno.make(payload) + buff = io.BytesIO() + qr.save(buff, kind="svg", scale=6) + return buff.getvalue() + + +def _call_webhook(webhook_id: str) -> bool: + req = urllib.request.Request( + f"{HA_BASE}/api/webhook/{webhook_id}", + method="POST", + data=b"", + ) + try: + with urllib.request.urlopen(req, timeout=4) as res: + return 200 <= res.status < 300 + except Exception: + return False + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/qr/wifi.svg": + payload = f"WIFI:T:WPA;S:{WIFI_SSID};P:{WIFI_PASS};;" + data = _svg_bytes(payload) + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Cache-Control", "public, max-age=300") + self.end_headers() + self.wfile.write(data) + return + + if self.path == "/qr/portal.svg": + data = _svg_bytes(f"{PORTAL_BASE_URL}/index.html") + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Cache-Control", "public, max-age=300") + self.end_headers() + self.wfile.write(data) + return + + if self.path == "/qr/bojana.svg": + data = _svg_bytes(f"{PORTAL_BASE_URL}/bojana.html") + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Cache-Control", "public, max-age=300") + self.end_headers() + self.wfile.write(data) + return + + if self.path == "/healthz": + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"ok") + return + + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path in ("/api/light/on", "/light/on"): + ok = _call_webhook(WEBHOOK_ON) + self.send_response(204 if ok else 502) + self.end_headers() + return + + if self.path in ("/api/light/off", "/light/off"): + ok = _call_webhook(WEBHOOK_OFF) + self.send_response(204 if ok else 502) + self.end_headers() + return + + self.send_response(404) + self.end_headers() + + def log_message(self, *_): + return + + +if __name__ == "__main__": + HTTPServer((HOST, PORT), Handler).serve_forever()