From a8b7db37487756751b66b766d46679571783ec80 Mon Sep 17 00:00:00 2001 From: HomeCore Date: Tue, 15 Sep 2026 15:45:11 +0000 Subject: [PATCH] HomeCore Controller v0.1.0 initial release --- homecore_controller/CHANGELOG.md | 8 +++ homecore_controller/DOCS.md | 12 ++++ homecore_controller/Dockerfile | 11 ++++ homecore_controller/README.md | 3 + homecore_controller/app/main.py | 82 ++++++++++++++++++++++++++++ homecore_controller/config.yaml | 25 +++++++++ homecore_controller/requirements.txt | 5 ++ repository.yaml | 3 + 8 files changed, 149 insertions(+) create mode 100644 homecore_controller/CHANGELOG.md create mode 100644 homecore_controller/DOCS.md create mode 100644 homecore_controller/Dockerfile create mode 100644 homecore_controller/README.md create mode 100644 homecore_controller/app/main.py create mode 100644 homecore_controller/config.yaml create mode 100644 homecore_controller/requirements.txt create mode 100644 repository.yaml diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md new file mode 100644 index 0000000..cac1a36 --- /dev/null +++ b/homecore_controller/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0 +- Initial Owner/property binding. +- Persistent Ed25519 controller identity. +- Signed controller session authentication. +- Home Assistant version heartbeat. +- Ingress commissioning screen. diff --git a/homecore_controller/DOCS.md b/homecore_controller/DOCS.md new file mode 100644 index 0000000..5a98721 --- /dev/null +++ b/homecore_controller/DOCS.md @@ -0,0 +1,12 @@ +# HomeCore Controller + +HomeCore Controller binds a Home Assistant Green to one HomeCore Owner/property and provides the local controller identity used by HomeCore Cloud. + +For the trial: +1. Set `cloud_api` to the HomeCore Cloud API URL. +2. Start the app. +3. Open its Home Assistant ingress page. +4. Sign in using the customer's HomeCore Owner account. +5. Enter the property name and connect. + +Binding is one-time. Shared users are added by the Owner through HomeCore permissions, not by claiming the controller again. diff --git a/homecore_controller/Dockerfile b/homecore_controller/Dockerfile new file mode 100644 index 0000000..4548e5e --- /dev/null +++ b/homecore_controller/Dockerfile @@ -0,0 +1,11 @@ +FROM ghcr.io/home-assistant/base:latest +ARG BUILD_VERSION +ARG BUILD_ARCH +LABEL io.hass.version="$BUILD_VERSION" io.hass.type="app" io.hass.arch="$BUILD_ARCH" +RUN apk add --no-cache python3 py3-pip && python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt +COPY app /opt/homecore +WORKDIR /opt/homecore +CMD ["python", "main.py"] diff --git a/homecore_controller/README.md b/homecore_controller/README.md new file mode 100644 index 0000000..a76d9c5 --- /dev/null +++ b/homecore_controller/README.md @@ -0,0 +1,3 @@ +# HomeCore Controller App v0.1.0 + +Initial HomeCore commissioning agent for Home Assistant Green. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py new file mode 100644 index 0000000..9dae2ae --- /dev/null +++ b/homecore_controller/app/main.py @@ -0,0 +1,82 @@ +from pathlib import Path +import os,json,base64,hashlib,time,threading +import httpx +from fastapi import FastAPI,Form,HTTPException +from fastapi.responses import HTMLResponse +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +import uvicorn + +DATA=Path('/config');DATA.mkdir(parents=True,exist_ok=True) +STATE=DATA/'state.json';KEY=DATA/'controller_ed25519.pem';OPTIONS=Path('/data/options.json') + +def options(): + try:return json.loads(OPTIONS.read_text()) + except Exception:return {'cloud_api':'https://api.homecore.example.com'} +def ensure_key(): + if KEY.exists():return serialization.load_pem_private_key(KEY.read_bytes(),password=None) + k=Ed25519PrivateKey.generate();KEY.write_bytes(k.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));return k +KEYOBJ=ensure_key() +def pub_pem():return KEYOBJ.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo).decode() +def fingerprint():return hashlib.sha256(KEYOBJ.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)).hexdigest() +def load_state(): + try:return json.loads(STATE.read_text()) + except Exception:return {} +def save_state(s):STATE.write_text(json.dumps(s,indent=2)) +def supervisor_get(path): + token=os.getenv('SUPERVISOR_TOKEN') + if not token:return None + try: + r=httpx.get('http://supervisor'+path,headers={'Authorization':'Bearer '+token},timeout=5);r.raise_for_status();return r.json() + except Exception:return None + +def controller_session(): + s=load_state() + if not s.get('controller_id'):return None + api=options()['cloud_api'].rstrip('/') + with httpx.Client(timeout=15) as c: + ch=c.get(f"{api}/api/v1/controllers/{s['controller_id']}/challenge");ch.raise_for_status();challenge=ch.json()['challenge'] + sig=base64.b64encode(KEYOBJ.sign(challenge.encode())).decode() + r=c.post(f"{api}/api/v1/controllers/{s['controller_id']}/session",json={'challenge':challenge,'signature_b64':sig});r.raise_for_status();return r.json()['controller_token'] + +def heartbeat_loop(): + while True: + try: + s=load_state() + if s.get('controller_id'): + token=controller_session(); api=options()['cloud_api'].rstrip('/') + info=supervisor_get('/core/info') or {}; ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else '' + httpx.post(f"{api}/api/v1/controllers/{s['controller_id']}/heartbeat",headers={'Authorization':'Bearer '+token},json={'software_version':'0.1.0','ha_version':ha_ver},timeout=10) + except Exception:pass + time.sleep(60) + +app=FastAPI(title='HomeCore Controller',version='0.1.0') +STYLE='''''' + +@app.get('/',response_class=HTMLResponse) +def setup_page(): + s=load_state() + if s.get('controller_id'): + return STYLE+f'

HomeCore

Controller connected

Controller: {s["controller_id"]}

Property: {s.get("property_id")}

This Green is permanently bound to the HomeCore property.

' + return STYLE+'''

HomeCore

Set up this controller

Sign in with the customer's HomeCore Owner account. HomeCore will create or select the property and permanently bind this Home Assistant Green.

''' + +@app.post('/bind',response_class=HTMLResponse) +def bind(email:str=Form(...),password:str=Form(...),property_name:str=Form(...)): + if load_state().get('controller_id'):raise HTTPException(409,'Controller already bound') + api=options()['cloud_api'].rstrip('/') + with httpx.Client(timeout=20) as c: + r=c.post(api+'/api/v1/auth/login',json={'email':email,'password':password});r.raise_for_status();token=r.json()['access_token'];headers={'Authorization':'Bearer '+token} + r=c.get(api+'/api/v1/properties',headers=headers);r.raise_for_status();found=next((p for p in r.json() if p['name'].lower()==property_name.lower() and p.get('owner')),None) + if not found: + r=c.post(api+'/api/v1/properties',headers=headers,json={'name':property_name,'timezone':'Europe/London'});r.raise_for_status();found=r.json() + info=supervisor_get('/core/info') or {};ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else '' + payload={'property_id':found['id'],'public_key':pub_pem(),'fingerprint':fingerprint(),'software_version':'0.1.0','ha_version':ha_ver} + r=c.post(api+'/api/v1/controllers/bind',headers=headers,json=payload);r.raise_for_status();out=r.json() + save_state(out) + return HTMLResponse(STYLE+'

HomeCore

Connected

This controller is now bound to the customer property. Sign into the HomeCore app with the same Owner account.

') + +@app.get('/health') +def health():return {'status':'ok','bound':bool(load_state().get('controller_id')),'fingerprint':fingerprint()} + +threading.Thread(target=heartbeat_loop,daemon=True).start() +if __name__=='__main__':uvicorn.run(app,host='0.0.0.0',port=8099) diff --git a/homecore_controller/config.yaml b/homecore_controller/config.yaml new file mode 100644 index 0000000..38b1b9b --- /dev/null +++ b/homecore_controller/config.yaml @@ -0,0 +1,25 @@ +name: HomeCore Controller +version: "0.1.0" +slug: homecore_controller +description: HomeCore local controller agent for Home Assistant Green. +url: https://homecore.home-core.co.uk +init: false +arch: [aarch64] +homeassistant_api: true +hassio_api: true +hassio_role: homeassistant +ingress: true +ingress_port: 8099 +panel_icon: mdi:home-automation +startup: application +stage: experimental +backup: hot +panel_title: HomeCore Setup +boot: auto +options: + cloud_api: "https://api.homecore.home-core.co.uk" +schema: + cloud_api: str +map: + - type: addon_config + read_only: false diff --git a/homecore_controller/requirements.txt b/homecore_controller/requirements.txt new file mode 100644 index 0000000..3981a41 --- /dev/null +++ b/homecore_controller/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +httpx==0.28.1 +cryptography==45.0.6 +python-multipart==0.0.20 diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..5d5d759 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: HomeCore Apps +url: https://homecore.home-core.co.uk +maintainer: HomeCore