Skill: urosorozel/emporia-energy (version 1.0.0)

--- name: emporia-energy description: Direct Emporia Vue energy queries via Emporia cloud (PyEmVue) or local ESPHome API, including guidance on choosing/configuring cloud vs local modes and running list/summary/circuit commands. metadata: {"moltbot":{"emoji":"⚡","os":["darwin","linux","win32"],"requires":{"bins":["python3"],"env":["EMPORIA_MODE"]}}} --- # Emporia Energy Skill Use the scripts in `{baseDir}/scripts` to query Emporia Vue data directly. Modes are selected with `EMPORIA_MODE`: - `cloud`: Emporia cloud API via PyEmVue (like the Home Assistant integration) - `esphome`: Local ESPHome API (for flashed devices) ## Choose a mode (cloud vs local) - Use **cloud** if your Emporia device is still on stock firmware or you want the simplest setup. Requires Emporia account credentials and internet access. - Use **esphome** only if the device is flashed with ESPHome and on your LAN. Requires the device IP/hostname and native API access on port 6053. If you are unsure whether the device is ESPHome-flashed, choose cloud mode. ## Environment ### Cloud mode - `EMPORIA_EMAIL` - `EMPORIA_PASSWORD` Optional: - `EMPORIA_SCALE` (`MINUTE`, `SECOND`, `MINUTES_15`, `DAY`, `MONTH`) - defaults to `MINUTE` ### ESPHome mode - `ESPHOME_HOST` - `ESPHOME_PORT` (optional, default `6053`) - `ESPHOME_API_KEY` (Noise PSK, base64) or `ESPHOME_PASSWORD` (legacy) ## Configuration steps Cloud: 1. Set `EMPORIA_MODE=cloud`. 2. Set `EMPORIA_EMAIL` and `EMPORIA_PASSWORD`. 3. (Optional) Set `EMPORIA_SCALE` to control power vs energy units. 4. Run `list` first to confirm channels, then `summary` or `circuit <name>`. ESPHome: 1. Ensure the device is flashed with ESPHome and on your LAN. 2. Set `EMPORIA_MODE=esphome`. 3. Set `ESPHOME_HOST` to the device IP/hostname (not Home Assistant). 4. If the ESPHome node uses encryption, set `ESPHOME_API_KEY` (base64 Noise PSK). 5. Run `list` first to confirm channels, then `summary` or `circuit <name>`. ## Commands The scripts accept: - `summary` (default) - `list` - `circuit <name>` ## Usage Cloud: ``` export EMPORIA_MODE=cloud export EMPORIA_EMAIL="[REDACTED_SECRET]" export EMPORIA_PASSWORD="..." python {baseDir}/scripts/emporia_cloud.py summary ``` ESPHome: ``` export EMPORIA_MODE=esphome export ESPHOME_HOST="192.168.1.50" export ESPHOME_API_KEY="base64-noise-psk" python {baseDir}/scripts/emporia_esphome.py summary ``` ## Dependencies (pip by default) Cloud: ``` python3 -m venv .venv source .venv/bin/activate pip install -r {baseDir}/requirements-cloud.txt ``` ESPHome: ``` python3 -m venv .venv source .venv/bin/activate pip install -r {baseDir}/requirements-esphome.txt ``` Optional: you can use `uv` instead of `pip` if preferred. ## Output Scripts emit JSON with: - timestamp - unit - total (best-effort) - top circuits - channels used ## Safety - Never print secrets (passwords, tokens, keys). - Do not make hardware or panel wiring recommendations.

path: assets/example.env
content: EMPORIA_MODE=cloud
EMPORIA_EMAIL=[REDACTED_SECRET]
EMPORIA_PASSWORD=change-me
# EMPORIA_SCALE=MINUTE

# ESPHome mode (uncomment and set if using local API)
# EMPORIA_MODE=esphome
# ESPHOME_HOST=192.168.1.29
# ESPHOME_PORT=6053
# ESPHOME_API_KEY=base64-noise-psk
# ESPHOME_PASSWORD=legacy-password


path: references/README.md
content: # Emporia Energy Skill References

This skill supports two modes:

1) **Cloud** (PyEmVue) — uses Emporia account credentials and the Emporia cloud API.
2) **ESPHome** — uses a locally flashed Emporia device running ESPHome with the native API.

Use this file for additional notes, troubleshooting steps, or links you want the agent to consult
when refining the scripts or adjusting configuration.

## moltbot.json example (copy/paste)

Cloud:
```json
{
  "skills": {
    "entries": {
      "emporia-energy": {
        "enabled": true,
        "env": {
          "EMPORIA_MODE": "cloud",
          "EMPORIA_EMAIL": "[REDACTED_SECRET]",
          "EMPORIA_PASSWORD": "change-me",
          "EMPORIA_SCALE": "MINUTE"
        }
      }
    }
  }
}
```

ESPHome:
```json
{
  "skills": {
    "entries": {
      "emporia-energy": {
        "enabled": true,
        "env": {
          "EMPORIA_MODE": "esphome",
          "ESPHOME_HOST": "192.168.1.29",
          "ESPHOME_PORT": "6053",
          "ESPHOME_API_KEY": "base64-noise-psk"
        }
      }
    }
  }
}
```


path: requirements-cloud.txt
content: pyemvue==0.18.9


path: requirements-esphome.txt
content: aioesphomeapi>=18.0.0


path: scripts/common.py
content: import json
import os
import re
import sys
from datetime import datetime, timezone


def die(message, code=1):
    print(message, file=sys.stderr)
    raise SystemExit(code)


def get_env(name, required=True, default=None):
    value = os.environ.get(name, default)
    if required and not value:
        die(f"Missing required environment variable: {name}")
    return value


def now_iso(ts=None):
    if ts is None:
        ts = datetime.now(timezone.utc)
    if isinstance(ts, datetime):
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=timezone.utc)
        return ts.isoformat()
    return datetime.now(timezone.utc).isoformat()


def print_json(payload):
    json.dump(payload, sys.stdout, indent=2, sort_keys=False)
    print()


def normalize(text):
    if text is None:
        return ""
    return re.sub(r"\s+", " ", str(text)).strip().lower()


def match_channels(channels, query):
    if not query:
        return []
    needle = normalize(query)
    matches = []
    for ch in channels:
        haystack = " ".join(
            [
                normalize(ch.get("name")),
                normalize(ch.get("device")),
                normalize(ch.get("channel_num")),
            ]
        )
        if needle in haystack:
            matches.append(ch)
    return matches


def pick_total_channel(channels):
    if not channels:
        return None
    preferred = []
    fallback = []
    for ch in channels:
        name = normalize(ch.get("name"))
        channel_num = normalize(ch.get("channel_num"))
        if any(token in name for token in ["total", "mains", "main", "usage"]):
            preferred.append(ch)
        elif channel_num in ("1,2,3", "1,2"):
            preferred.append(ch)
        else:
            fallback.append(ch)
    if preferred:
        preferred.sort(key=lambda x: x.get("value") or 0, reverse=True)
        return preferred[0]
    fallback.sort(key=lambda x: x.get("value") or 0, reverse=True)
    return fallback[0]


def top_circuits(channels, total_channel, limit=5):
    items = [ch for ch in channels if ch is not total_channel]
    items.sort(key=lambda x: x.get("value") or 0, reverse=True)
    return items[:limit]


path: scripts/emporia_cloud.py
content: #!/usr/bin/env python3
import sys
from datetime import datetime, timezone

from pyemvue import PyEmVue
from pyemvue.enums import Scale

from common import (
    die,
    get_env,
    match_channels,
    now_iso,
    pick_total_channel,
    print_json,
    top_circuits,
)


def parse_args(argv):
    if len(argv) < 2:
        return "summary", None
    command = argv[1].lower()
    if command in ("summary", "list"):
        return command, None
    if command == "circuit":
        if len(argv) < 3:
            die("Usage: emporia_cloud.py circuit <name>")
        return command, " ".join(argv[2:]).strip()
    die("Usage: emporia_cloud.py [summary|list|circuit <name>]")


def scale_usage(usage, scale):
    if usage is None:
        return 0.0
    if scale == Scale.MINUTE.value:
        return 60 * 1000 * usage
    if scale == Scale.SECOND.value:
        return 3600 * 1000 * usage
    if scale == Scale.MINUTES_15.value:
        return 4 * 1000 * usage
    return usage


def unit_for_scale(scale):
    if scale in (Scale.MINUTE.value, Scale.SECOND.value, Scale.MINUTES_15.value):
        return "W"
    return "kWh"


def login(vue, email, password):
    if email.startswith("vue_simulator@"):
        host = email.split("@", 1)[1]
        return vue.login_simulator(host)
    return vue.login(email, password)


def collect_devices(vue):
    devices = vue.get_devices()
    device_map = {}
    channel_map = {}
    gids = []
    for device in devices:
        gids.append(device.device_gid)
        device_map[device.device_gid] = device
        for channel in device.channels:
            channel_map[(device.device_gid, channel.channel_num)] = {
                "device": device.device_name,
                "name": channel.name or f"Channel {channel.channel_num}",
                "channel_num": channel.channel_num,
                "device_gid": device.device_gid,
            }
    return gids, device_map, channel_map


def fetch_usage(vue, gids, scale):
    timestamp = datetime.now(timezone.utc)
    usage_devices = vue.get_device_list_usage(gids, timestamp, scale)
    if not usage_devices:
        return [], timestamp
    channels = []
    for usage in usage_devices.values():
        ts = usage.timestamp or timestamp
        if not usage.channels:
            continue
        for ch in usage.channels.values():
            channels.append(
                {
                    "device_gid": ch.device_gid,
                    "channel_num": ch.channel_num,
                    "usage": ch.usage,
                    "timestamp": ts,
                }
            )
    return channels, timestamp


def main(argv):
    command, query = parse_args(argv)
    email = get_env("EMPORIA_EMAIL")
    [REDACTED_SECRET]"EMPORIA_PASSWORD")
    scale_env = get_env("EMPORIA_SCALE", required=False, default="MINUTE")

    try:
        scale = Scale[scale_env.upper()].value
    except KeyError:
        die("EMPORIA_SCALE must be one of: MINUTE, SECOND, MINUTES_15, DAY, MONTH")

    vue = PyEmVue()
    if not login(vue, email, password):
        die("Failed to login to Emporia")

    gids, device_map, channel_map = collect_devices(vue)
    if not gids:
        die("No Emporia devices found")

    usage_channels, fallback_ts = fetch_usage(vue, gids, scale)
    unit = unit_for_scale(scale)

    channels = []
    for usage in usage_channels:
        key = (usage["device_gid"], usage["channel_num"])
        meta = channel_map.get(key, {})
        value = scale_usage(usage.get("usage"), scale)
        channels.append(
            {
                "device_gid": usage["device_gid"],
                "device": meta.get("device"),
                "name": meta.get("name"),
                "channel_num": usage["channel_num"],
                "value": round(value, 3),
                "unit": unit,
                "timestamp": now_iso(usage.get("timestamp") or fallback_ts),
            }
        )

    if command == "list":
        payload = {
            "timestamp": now_iso(),
            "mode": "cloud",
            "count": len(channels),
            "channels": [
                {
                    "device_gid": ch["device_gid"],
                    "device": ch["device"],
                    "name": ch["name"],
                    "channel_num": ch["channel_num"],
                }
                for ch in channels
            ],
        }
        print_json(payload)
        return

    if command == "circuit":
        matches = match_channels(channels, query)
        payload = {
            "timestamp": now_iso(),
            "mode": "cloud",
            "query": query,
            "unit": unit,
            "matches": matches,
        }
        print_json(payload)
        return

    total_channel = pick_total_channel(channels)
    top = top_circuits(channels, total_channel)
    payload = {
        "timestamp": now_iso(),
        "mode": "cloud",
        "unit": unit,
        "scale": scale,
        "total": total_channel,
        "top_circuits": top,
        "channels_used": len(channels),
    }
    print_json(payload)


if __name__ == "__main__":
    main(sys.argv)


path: scripts/emporia_esphome.py
content: #!/usr/bin/env python3
import asyncio
import base64
import sys

from aioesphomeapi import APIClient

from common import (
    die,
    get_env,
    match_channels,
    now_iso,
    pick_total_channel,
    print_json,
    top_circuits,
)


def parse_args(argv):
    if len(argv) < 2:
        return "summary", None
    command = argv[1].lower()
    if command in ("summary", "list"):
        return command, None
    if command == "circuit":
        if len(argv) < 3:
            die("Usage: emporia_esphome.py circuit <name>")
        return command, " ".join(argv[2:]).strip()
    die("Usage: emporia_esphome.py [summary|list|circuit <name>]")


def parse_encryption(api_key, password):
    if api_key:
        try:
            decoded = base64.b64decode(api_key)
            if len(decoded) == 32:
                return None, decoded
        except Exception:
            pass
        return api_key, None
    return password, None


def state_value(state):
    for attr in ("state", "value", "text"):
        if hasattr(state, attr):
            return getattr(state, attr)
    return None


async def fetch_states(host, port, password, noise_psk):
    client = APIClient(host, port, [REDACTED_SECRET], noise_psk=noise_psk)
    await client.connect(login=True)
    entities, _ = await client.list_entities_services()
    states = await client.get_states()
    await client.disconnect()
    return entities, states


def build_channels(entities, states):
    entity_by_key = {entity.key: entity for entity in entities}
    channels = []
    for state in states:
        info = entity_by_key.get(state.key)
        name = getattr(info, "name", None)
        unit = getattr(info, "unit_of_measurement", None)
        value = state_value(state)
        if not isinstance(value, (int, float)):
            continue
        channels.append(
            {
                "key": state.key,
                "name": name,
                "unit": unit,
                "value": round(float(value), 3),
            }
        )
    return channels


def main(argv):
    command, query = parse_args(argv)
    host = get_env("ESPHOME_HOST")
    port = int(get_env("ESPHOME_PORT", required=False, default="6053"))
    [REDACTED_SECRET]"ESPHOME_API_KEY", required=False, default=None)
    [REDACTED_SECRET]"ESPHOME_PASSWORD", required=False, default=None)
    password, noise_psk = parse_encryption(api_key, password)

    try:
        entities, states = asyncio.run(fetch_states(host, port, password, noise_psk))
    except Exception as exc:
        die(f"Failed to query ESPHome API: {exc}")

    channels = build_channels(entities, states)
    if not channels:
        die("No numeric sensor channels found from ESPHome")

    if command == "list":
        payload = {
            "timestamp": now_iso(),
            "mode": "esphome",
            "count": len(channels),
            "channels": [
                {"key": ch["key"], "name": ch["name"], "unit": ch["unit"]}
                for ch in channels
            ],
        }
        print_json(payload)
        return

    if command == "circuit":
        matches = match_channels(channels, query)
        payload = {
            "timestamp": now_iso(),
            "mode": "esphome",
            "query": query,
            "matches": matches,
        }
        print_json(payload)
        return

    total_channel = pick_total_channel(channels)
    top = top_circuits(channels, total_channel)
    unit = total_channel.get("unit") if total_channel else None
    payload = {
        "timestamp": now_iso(),
        "mode": "esphome",
        "unit": unit,
        "total": total_channel,
        "top_circuits": top,
        "channels_used": len(channels),
    }
    print_json(payload)


if __name__ == "__main__":
    main(sys.argv)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)