diff --git a/.env.example b/.env.example index f4b012a..7ddadcc 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ HA_TOKEN="paste_your_long_lived_access_token_here" DATA_DIR="./data" REPORT_DIR="./reports" WEB_DIR="./web" +SITE_BASE_PATH="/" +SITE_URL="http://localhost" # Extra owner directions appended to the 05:00 AI prompt PROMPT_FILE="./llm_instructions.md" diff --git a/README.md b/README.md index f6273af..b7b1f65 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Cron-friendly Home Assistant observer: - every 30 minutes: collect compact Home Assistant snapshots into `./data` - every day at 05:00: send the last day of snapshots to AI -- publish a funny local blog at `./web/index.html` with daily article archive links +- publish a funny local blog with daily article archive links - save Markdown AI reports in `./reports` ## Setup @@ -105,12 +105,26 @@ Run the 05:00-style analysis/publishing step: Open the blog served by nginx: ```text -http://localhost/haobserver/ +http://localhost/ ``` -Daily articles are written under `web/articles/YYYY-MM-DD.html`, and `index.html` links to the archive. New articles include context from previous reports from the last `ARTICLE_CONTEXT_DAYS` days. +This instance publishes to the web root with: -This uses a symlink from `/var/www/html/haobserver` to the project's `./web` directory. +```bash +WEB_DIR="/var/www/html" +SITE_BASE_PATH="/" +SITE_URL="http://piagent" +``` + +For a subdirectory install, use for example: + +```bash +WEB_DIR="/var/www/html/haobserver" +SITE_BASE_PATH="/haobserver" +SITE_URL="http://piagent" +``` + +Daily articles are written under `articles/YYYY-MM-DD.html` inside `WEB_DIR`, and `index.html` links to the archive. An RSS feed is published at `rss.xml`, and a sci-fi favicon is published at `favicon.svg`. New articles include context from previous reports from the last `ARTICLE_CONTEXT_DAYS` days. ## Install cron jobs @@ -135,8 +149,7 @@ Manual crontab equivalent: ```text /home/hbrain/haobserver/data/ 30-minute JSON snapshots /home/hbrain/haobserver/reports/ daily Markdown AI reports -/home/hbrain/haobserver/web/ local funny blog, index.html and articles/*.html -/var/www/html/haobserver symlink to web/ for nginx +/var/www/html/ local funny blog, index.html, rss.xml, favicon.svg, and articles/*.html /home/hbrain/haobserver/cron.log cron logs ``` diff --git a/ha_observer.py b/ha_observer.py index a64b9b3..823f480 100755 --- a/ha_observer.py +++ b/ha_observer.py @@ -19,6 +19,7 @@ import re import subprocess import sys from datetime import datetime, timedelta, timezone +from email.utils import format_datetime from pathlib import Path from typing import Any @@ -30,6 +31,8 @@ HA_TOKEN = os.environ.get("HA_TOKEN", "") DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) REPORT_DIR = Path(os.environ.get("REPORT_DIR", "./reports")) WEB_DIR = Path(os.environ.get("WEB_DIR", "./web")) +SITE_BASE_PATH = os.environ.get("SITE_BASE_PATH", "/").strip() or "/" +SITE_URL = os.environ.get("SITE_URL", "http://localhost").rstrip("/") PROMPT_FILE = Path(os.environ.get("PROMPT_FILE", "./llm_instructions.md")) HISTORY_HOURS = int(os.environ.get("HISTORY_HOURS", "24")) MAX_HISTORY_PER_ENTITY = int(os.environ.get("MAX_HISTORY_PER_ENTITY", "20")) @@ -348,31 +351,80 @@ def markdownish_to_html(text: str) -> str: BLOG_CSS = """ - :root { color-scheme: dark; } - body { margin:0; font-family: Georgia, 'Times New Roman', serif; background:#101018; color:#eeeef6; line-height:1.65; } - header { border-bottom:1px solid #303044; background:linear-gradient(135deg,#18182a,#25193a); } - .wrap { max-width:980px; margin:0 auto; padding:1.5rem; } - .masthead { padding:2.4rem 1.5rem; } - .kicker { color:#fbbf24; text-transform:uppercase; letter-spacing:.14em; font:700 .78rem system-ui,sans-serif; } - h1 { margin:.2rem 0; font-size:clamp(2.2rem,6vw,4.8rem); line-height:1; } - h2,h3 { color:#fde68a; line-height:1.2; } - article { background:#181827; border:1px solid #33334a; border-radius:22px; padding:clamp(1rem,3vw,2rem); box-shadow:0 18px 45px #0007; } - article p, article li { font-size:1.05rem; } - .layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:1.25rem; align-items:start; } - aside { background:#171724; border:1px solid #303044; border-radius:18px; padding:1rem; position:sticky; top:1rem; } + :root { color-scheme: dark; --cyan:#00f5ff; --blue:#2777ff; --violet:#8b5cf6; --amber:#fbbf24; --panel:#07111fcc; --line:#1de7ff66; } + * { box-sizing:border-box; } + body { + margin:0; min-height:100vh; color:#dff9ff; line-height:1.7; + font-family:'Rajdhani','Orbitron','Eurostile',system-ui,sans-serif; + background: + radial-gradient(circle at 16% 10%, #1746ff55 0 12rem, transparent 28rem), + radial-gradient(circle at 82% 4%, #00f5ff30 0 10rem, transparent 24rem), + radial-gradient(circle at 50% 100%, #6d28d955 0 15rem, transparent 34rem), + linear-gradient(135deg,#02040a 0%,#07111f 48%,#030712 100%); + overflow-x:hidden; + } + body::before { + content:""; position:fixed; inset:0; pointer-events:none; opacity:.34; + background-image: + linear-gradient(#00f5ff16 1px, transparent 1px), + linear-gradient(90deg,#00f5ff16 1px, transparent 1px), + linear-gradient(115deg, transparent 0 48%, #7dd3fc22 50%, transparent 52% 100%); + background-size:54px 54px,54px 54px,180px 180px; + mask-image:linear-gradient(to bottom,#000 0%,#000 55%,transparent 100%); + } + body::after { + content:""; position:fixed; inset:0; pointer-events:none; opacity:.14; + background:repeating-linear-gradient(to bottom, transparent 0 3px, #ffffff 4px 5px); + mix-blend-mode:screen; + } + header { position:relative; border-bottom:1px solid var(--line); background:linear-gradient(90deg,#020617dd,#051b33bb,#020617dd); box-shadow:0 0 42px #00d9ff22; } + header::before, header::after { content:""; position:absolute; top:0; bottom:0; width:18vw; border-color:var(--cyan); opacity:.65; pointer-events:none; } + header::before { left:0; border-top:2px solid; border-left:2px solid; clip-path:polygon(0 0,100% 0,35% 100%,0 100%); } + header::after { right:0; border-top:2px solid; border-right:2px solid; clip-path:polygon(0 0,100% 0,100% 100%,65% 100%); } + .wrap { max-width:1180px; margin:0 auto; padding:1.5rem; position:relative; } + .masthead { padding:3rem 1.5rem 2.6rem; text-align:center; } + .kicker { color:var(--cyan); text-transform:uppercase; letter-spacing:.28em; font:800 .78rem system-ui,sans-serif; text-shadow:0 0 14px #00f5ff; } + h1 { margin:.35rem 0; font-size:clamp(2.4rem,7vw,6rem); line-height:.9; text-transform:uppercase; letter-spacing:.05em; color:#f8feff; text-shadow:0 0 12px #00f5ff,0 0 38px #2777ff; } + h2,h3 { color:#c8fbff; line-height:1.15; text-transform:uppercase; letter-spacing:.06em; text-shadow:0 0 12px #00f5ff88; } + article, aside { + position:relative; background:linear-gradient(180deg,#071827d9,#050914e6); border:1px solid var(--line); + clip-path:polygon(0 18px,18px 0,100% 0,100% calc(100% - 18px),calc(100% - 18px) 100%,0 100%); + box-shadow:0 0 0 1px #2777ff22 inset,0 0 34px #00d9ff18,0 24px 60px #000b; + } + article::before, aside::before { content:""; position:absolute; inset:0; pointer-events:none; border:1px solid #ffffff12; clip-path:inherit; } + article { padding:clamp(1.1rem,3vw,2.2rem); } + article p, article li { font-size:1.06rem; color:#e6fbff; } + article h1 { font-size:clamp(1.8rem,4vw,3.5rem); text-align:left; } + .layout { display:grid; grid-template-columns:minmax(0,1fr) 310px; gap:1.35rem; align-items:start; } + aside { padding:1.1rem; position:sticky; top:1rem; } .archive { list-style:none; margin:0; padding:0; } - .archive li { border-bottom:1px solid #2b2b3f; padding:.55rem 0; } + .archive li { border-bottom:1px solid #22d3ee33; padding:.7rem 0; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; } + .archive li::before { content:"▸ "; color:var(--cyan); text-shadow:0 0 10px var(--cyan); } .archive li:last-child { border-bottom:0; } - a { color:#93c5fd; text-decoration:none; } - a:hover { text-decoration:underline; } - .meta { color:#b7b7c8; font: .95rem system-ui,sans-serif; } - details { margin-top:1.5rem; } - pre { white-space:pre-wrap; background:#0b0b11; color:#d1d5db; padding:1rem; border-radius:12px; overflow:auto; font-size:.85rem; } - footer { color:#9999aa; text-align:center; padding:2rem; font: .9rem system-ui,sans-serif; } - @media (max-width:800px) { .layout { grid-template-columns:1fr; } aside { position:static; } } + a { color:#67e8f9; text-decoration:none; text-shadow:0 0 9px #00f5ff77; } + a:hover { color:white; text-decoration:none; filter:drop-shadow(0 0 8px var(--cyan)); } + .meta { color:#9eeaff; font:.95rem ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.04em; } + details { margin-top:1.5rem; border-top:1px solid #22d3ee33; padding-top:1rem; } + summary { cursor:pointer; color:var(--amber); text-transform:uppercase; letter-spacing:.08em; } + pre { white-space:pre-wrap; background:#01040acc; color:#bff8ff; padding:1rem; border:1px solid #22d3ee44; border-radius:0; overflow:auto; font-size:.82rem; box-shadow:0 0 22px #00d9ff11 inset; } + footer { color:#7dd3fc; text-align:center; padding:2rem; font:.82rem ui-monospace,SFMono-Regular,Menlo,monospace; text-transform:uppercase; letter-spacing:.12em; } + @media (max-width:850px) { .layout { grid-template-columns:1fr; } aside { position:static; } .masthead { text-align:left; } } """ +def site_href(relative_path: str = "") -> str: + base = SITE_BASE_PATH + if not base.startswith("/"): + base = f"/{base}" + if not base.endswith("/"): + base = f"{base}/" + return f"{base}{relative_path.lstrip('/')}" + + +def site_url(relative_path: str = "") -> str: + return f"{SITE_URL}{site_href(relative_path)}" + + def article_links() -> str: articles_dir = WEB_DIR / "articles" if not articles_dir.exists(): @@ -384,24 +436,90 @@ def article_links() -> str: label = datetime.strptime(path.stem, "%Y-%m-%d").strftime("%A, %B %-d, %Y") except ValueError: pass - links.append(f'
  • {html.escape(label)}
  • ') + href = site_href(f"articles/{path.name}") + links.append(f'
  • {html.escape(label)}
  • ') return "\n".join(links) or "
  • No articles yet. The raccoon newsroom is warming up.
  • " -def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str, article_href_prefix: str = "") -> str: - archive = archive_links.replace('href="articles/', f'href="{article_href_prefix}articles/') +def write_favicon() -> Path: + favicon = f""" + + + + + + + + + + + + + + + + +""" + path = WEB_DIR / "favicon.svg" + path.write_text(favicon, encoding="utf-8") + return path + + +def write_rss_feed() -> Path: + articles_dir = WEB_DIR / "articles" + items = [] + for path in sorted(articles_dir.glob("*.html"), reverse=True)[:20]: + title = path.stem + try: + title = datetime.strptime(path.stem, "%Y-%m-%d").strftime("Smart Home Briefing - %A, %B %-d, %Y") + except ValueError: + title = f"Smart Home Briefing - {path.stem}" + content = path.read_text(encoding="utf-8", errors="ignore") + description = re.sub(r"<[^>]+>", " ", content) + description = re.sub(r"\s+", " ", html.unescape(description)).strip()[:500] + pub_dt = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) + url = site_url(f"articles/{path.name}") + items.append(f""" + + {html.escape(title)} + {html.escape(url)} + {html.escape(url)} + {format_datetime(pub_dt, usegmt=True)} + {html.escape(description)} + """) + now = format_datetime(datetime.now(timezone.utc), usegmt=True) + feed = f""" + + + Smart Home Gossip Gazette + {html.escape(site_url())} + Daily Home Assistant smart-home briefings from the orbital raccoon telemetry desk. + en + {now} +{''.join(items)} + + +""" + path = WEB_DIR / "rss.xml" + path.write_text(feed, encoding="utf-8") + return path + + +def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str) -> str: return f""" {html.escape(title)} + +
    -
    🦝 Smart Home Gossip Gazette
    +
    ◇ orbital home telemetry // raccoon intelligence unit ◇

    {html.escape(title)}

    {html.escape(subtitle)}

    @@ -409,11 +527,12 @@ def blog_shell(title: str, subtitle: str, main_content: str, archive_links: str,
    {main_content}
    - + """ @@ -438,13 +557,13 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: """ article_path = articles_dir / article_name + article_path.touch(exist_ok=True) article_path.write_text( blog_shell( "Smart Home Gossip Gazette", f"Daily home intelligence briefing · Generated {now}", article_content, article_links(), - article_href_prefix="../", ), encoding="utf-8", ) @@ -453,7 +572,7 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path:

    Latest article · {html.escape(now)}

    {body} -

    Permanent link for this article →

    +

    Permanent link for this article →

    """ index_path = WEB_DIR / "index.html" @@ -461,6 +580,8 @@ def publish_webpage(conclusions: str, raw_summary: str) -> Path: blog_shell("Smart Home Gossip Gazette", "A daily blog of your Home Assistant household signals", featured, article_links()), encoding="utf-8", ) + write_favicon() + write_rss_feed() return article_path diff --git a/llm_instructions.md b/llm_instructions.md index 390afba..de3d95a 100644 --- a/llm_instructions.md +++ b/llm_instructions.md @@ -13,6 +13,8 @@ Suggested directions: - If data is missing or ambiguous, say so instead of pretending. - Avoid being creepy about personal habits; summarize respectfully. - Prefer concise bullet points over long paragraphs. +- entities marked smb_ are located in different house in Samobor, Croatia, others are in Sonderborg Denmark +- people FJR and Megane are my motorcycle and car not persons at home Optional custom questions to answer: