Skill: wittiot/wittiot-device-skill (version 1.0.0)

--- name: wittiot-device-skill description: WittIoT气象站数据查询，支持WittStation系列气象站，提供实时温湿度、气压、光照、风速风向、降雨量等传感器数据查询，以及24小时/7天/30天历史趋势查询。也支持通过设备短码（shortcode）免登录查询公开气象站数据。 metadata: openclaw: requires: env: - WITTIOT_API_KEY primaryEnv: WITTIOT_API_KEY --- # WittIoT 气象站数据查询 Skill WittIoT 气象站数据查询服务，支持查询绑定在你账户下的 WittStation 气象站的实时与历史传感器数据，以及通过设备短码查询他人公开气象站的实时数据（无需登录）。 ## 功能特性 - 实时气象数据查询：温度、湿度、气压、光照、风速、阵风、风向、降雨量 - 历史趋势查询：24小时 / 7天 / 30天历史数据 - 设备列表查询：列出账户下所有绑定的气象站及其在线状态 - 公开短码查询：通过6位短码免登录查询他人公开气象站实时数据（无需 API Key） ## 首次配置 首次使用时需要配置 WittIoT API Key： 1. 访问 https://wittiot.com/index/apikey 创建 API Key（格式：`witt_sk_xxxx`） 2. 设置环境变量：`export WITTIOT_API_KEY=witt_sk_your_key_here` 3. 或运行时自动提示输入并保存到本地 `config.json` > 短码查询功能（`--action shortcode`）无需 API Key，任何人都可以直接使用。 ## 触发条件 当用户表达了以下意图之一时，使用此 Skill： - 查询自己气象站的实时天气（如"我的气象站现在温度多少"、"家里风速是多少"、"雨量怎么样"） - 查询气象站历史趋势（如"昨天24小时气温走势"、"上周的降雨量记录"） - 列出名下设备（如"我绑定了哪些气象站"、"查看我的WittStation设备列表"） - 查询他人公开气象站（如"查一下短码 ABC123 的天气数据"、"这台气象站现在的数据是什么"） --- ## 执行步骤 ### 第一步：判断查询类型 根据用户意图判断操作类型： | 用户意图 | 操作（--action） | 是否需要 API Key | |---------|----------------|----------------| | 查询实时天气 | `realtime` | ✅ 需要 | | 查询历史数据 | `history` | ✅ 需要 | | 列出设备 | `devices` | ✅ 需要 | | 通过短码查询公开数据 | `shortcode` | ❌ 不需要 | ### 第二步：检查 API Key（shortcode 操作跳过此步） - 如果用户已配置环境变量 `WITTIOT_API_KEY` 或本地 `config.json` 中有 `apiKey`，直接使用 - 如果未配置，**先提示用户提供 API Key**，等待用户回复后继续 **请求 API Key 的回复模板：** ``` 🔑 查询 WittIoT 气象站数据需要 API Key，请提供你的 Key。 （在 https://wittiot.com/index/apikey 可以免费创建，格式为 witt_sk_xxxx） ``` ### 第三步：运行查询脚本 #### 查询实时数据 ```bash export WITTIOT_API_KEY=witt_sk_your_key_here # 自动选择唯一设备（账户只有1台时） python3 scripts/wittiot_query.py --action realtime # 指定设备名称 python3 scripts/wittiot_query.py --action realtime --device-name "Backyard Station" # 指定设备 ID python3 scripts/wittiot_query.py --action realtime --device-id 1 ``` #### 查询历史数据 ```bash # 24小时历史（默认） python3 scripts/wittiot_query.py --action history --device-name "Backyard Station" # 7天历史 python3 scripts/wittiot_query.py --action history --device-name "Backyard Station" --range 7d # 30天历史 python3 scripts/wittiot_query.py --action history --device-id 1 --range 30d ``` #### 列出设备 ```bash python3 scripts/wittiot_query.py --action devices ``` #### 通过短码查询公开气象站（无需 API Key） ```bash python3 scripts/wittiot_query.py --action shortcode --code ABC123 ``` ### 第四步：处理脚本输出 #### 设备列表输出（--action devices） ```json { "devices": [ { "id": 1, "name": "Backyard Station", "model": "WS6900", "sn": "WS6900-000001", "online": true, "visibility": "public" } ] } ``` 以友好的方式列出设备，标注在线/离线状态和公开/私有模式。 #### 实时数据输出（--action realtime） ```json { "device_id": 1, "device_name": "Backyard Station", "online": true, "sensors": { "temperature": "23.5 °C", "humidity": "68.2 %", "pressure": "1013.2 hPa", "light": "45.3 Klux", "wind_speed": "3.2 m/s", "wind_gust": "5.1 m/s", "wind_direction": "270° (W)", "rainfall": "0.0 mm" }, "_raw": { ... } } ``` 以自然语言向用户汇报数据，例如： > 🌡 你的「Backyard Station」气象站当前数据（在线）：温度 **23.5°C**，湿度 **68%**，气压 **1013.2 hPa**，西风 **3.2 m/s**，无降雨。 #### 多设备选择输出 当账户有多台设备且用户未指定时，脚本返回： ```json {"choose_device": [{"id": 1, "name": "Backyard Station"}, {"id": 2, "name": "Rooftop Station"}]} ``` 此时应**提示用户选择设备**，然后用 `--device-name` 或 `--device-id` 重新运行。 **提示用户选择设备的回复模板：** ``` 📡 你有以下气象站，请告诉我查询哪一台： 1. Backyard Station 2. Rooftop Station ``` #### 历史数据输出（--action history） ```json { "device_id": 1, "range": "24h", "count": 144, "data": [ {"timestamp": 1743000000, "temperature": 22.1, "humidity": 65.0}, ... ] } ``` 对历史数据进行趋势分析：最高/最低/平均值，并以自然语言描述趋势。 #### 短码查询输出（--action shortcode） ```json { "shortcode": "ABC123", "device_name": "Community Weather Station", "model": "WS6900", "online": true, "sensors": { "temperature": "21.0 °C", "humidity": "72.0 %" } } ``` --- ### 错误码与消息处理 当脚本返回错误时，使用以下处理方式： | 情况 | 处理 | |------|------| | `error: Missing API key` | 提示用户提供 API Key（见第二步模板） | | `error: HTTP 401` | 提示 API Key 无效，请重新获取 | | `error: No devices found` | 提示用户尚未绑定气象站，引导访问 https://wittiot.com/index/dashboard | | `error: Device 'xxx' not found` + `available` 列表 | 提示可用设备名称，请用户重新选择 | | `error: Shortcode not found or device is private` | 提示短码不存在或设备未设为公开 | | `choose_device` | 列出设备供用户选择（见第四步） | | 设备 `online: false` | 告知用户设备当前离线，数据可能不是最新 | **401 固定回复文案：** ``` 🔑 API Key 无效或已过期，请访问 https://wittiot.com/index/apikey 重新生成后提供。 ``` --- ## 配置管理 配置文件位于 `config.json`（参考 `config.example.json`）： ```json { "apiKey": "witt_sk_your_api_key_here" } ``` 设置 Key 的方式（优先级从高到低）： 1. **命令行参数**：`--api-key witt_sk_xxx` 2. **环境变量**：`export WITTIOT_API_KEY=witt_sk_xxx` 3. **配置文件**：`config.json` 中的 `apiKey` 字段 --- ## 资源 ### scripts/ - `wittiot_query.py` — WittIoT 气象站数据查询脚本（Python 3，无外部依赖） ### references/ - `api-spec.md` — WittIoT API 端点规范与响应结构说明

path: config.example.json
content: apiKey: witt_sk_your_api_key_here

path: _meta.json
content: slug: wittiot-device-skill
version: 1.0.0

path: scripts/wittiot_query.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WittIoT weather station query skill.

Supported actions:
  devices   — list all devices bound to the account
  realtime  — get real-time sensor readings for a device
  history   — get historical data for a device
  shortcode — get public data by shortcode (no auth needed)

Auth: WITTIOT_API_KEY env var, or --api-key arg, or config.json
"""

import argparse
import json
import os
import sys
import urllib.request
import urllib.parse
from typing import Any, Dict, List, Optional

BASE_URL = "https://wittiot.com"

WIND_DIR_LABELS = {
    (0, 22): "N", (22, 67): "NE", (67, 112): "E", (112, 157): "SE",
    (157, 202): "S", (202, 247): "SW", (247, 292): "W", (292, 337): "NW", (337, 360): "N",
}


def deep_get(data: Any, path: str) -> Any:
    cur = data
    for part in path.split("."):
        if isinstance(cur, dict) and part in cur:
            cur = cur[part]
        else:
            return None
    return cur


def http_request(
    method: str,
    url: str,
    headers: Dict[str, str] = None,
    payload: Dict[str, Any] = None,
) -> Dict[str, Any]:
    headers = headers or {}
    data = None
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers.setdefault("Content-Type", "application/json")
    req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        try:
            return json.loads(body)
        except Exception:
            raise SystemExit(f"HTTP {e.code}: {body[:200]}")


def load_config() -> Dict[str, Any]:
    path = os.path.join(os.getcwd(), "config.json")
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return {}


def resolve_api_key(cfg: Dict[str, Any], arg_key: Optional[str]) -> Optional[str]:
    if arg_key:
        return arg_key
    env_key = os.getenv("WITTIOT_API_KEY")
    if env_key:
        return env_key
    return cfg.get("apiKey")


def make_headers([REDACTED_SECRET]) -> Dict[str, str]:
    headers: Dict[str, str] = {"Accept": "application/json"}
    if [REDACTED_SECRET]"X-API-Key"] = api_key
    return headers


def wind_direction_label(degrees: Optional[float]) -> str:
    if degrees is None:
        return "unknown"
    d = float(degrees) % 360
    for (lo, hi), label in WIND_DIR_LABELS.items():
        if lo <= d < hi:
            return label
    return "N"


def format_sensor_data(data: Dict[str, Any]) -> Dict[str, Any]:
    """Format raw sensor data into human-readable output."""
    out: Dict[str, Any] = {}
    if data.get("temperature") is not None:
        out["temperature"] = f"{data['temperature']} °C"
    if data.get("humidity") is not None:
        out["humidity"] = f"{data['humidity']} %"
    if data.get("pressure") is not None:
        out["pressure"] = f"{data['pressure']} hPa"
    if data.get("light") is not None:
        out["light"] = f"{data['light']} Klux"
    if data.get("wind_speed") is not None:
        out["wind_speed"] = f"{data['wind_speed']} m/s"
    if data.get("wind_gust") is not None:
        out["wind_gust"] = f"{data['wind_gust']} m/s"
    if data.get("wind_direction") is not None:
        label = wind_direction_label(data["wind_direction"])
        out["wind_direction"] = f"{data['wind_direction']}° ({label})"
    if data.get("rainfall") is not None:
        out["rainfall"] = f"{data['rainfall']} mm"
    return out


# ─────────────────────────────── actions ────────────────────────────────

def action_devices(api_key: str) -> None:
    url = f"{BASE_URL}/api/v1.Device/index"
    resp = http_request("GET", url, headers=make_headers(api_key))
    if resp.get("code") != 200:
        print(json.dumps({"error": resp.get("msg", "Unknown error"), "code": resp.get("code")}, ensure_ascii=False))
        sys.exit(2)
    devices: List[Dict] = resp.get("data", [])
    if not devices:
        print(json.dumps({"devices": [], "message": "No devices found"}, ensure_ascii=False))
        return
    out = []
    for d in devices:
        out.append({
            "id": d.get("id"),
            "name": d.get("name"),
            "model": d.get("model"),
            "sn": d.get("sn"),
            "online": d.get("online"),
            "visibility": d.get("visibility"),
        })
    print(json.dumps({"devices": out}, ensure_ascii=False))


def action_realtime(api_key: str, device_id: Optional[int], device_name: Optional[str], devices_cache: Optional[List] = None) -> None:
    # Resolve device_id from name if needed
    if device_id is None and device_name:
        if devices_cache is None:
            url = f"{BASE_URL}/api/v1.Device/index"
            resp = http_request("GET", url, headers=make_headers(api_key))
            if resp.get("code") != 200:
                print(json.dumps({"error": resp.get("msg", "Failed to list devices")}, ensure_ascii=False))
                sys.exit(2)
            devices_cache = resp.get("data", [])
        matched = [d for d in devices_cache if d.get("name") == device_name]
        if not matched:
            names = [d.get("name") for d in devices_cache]
            print(json.dumps({"error": f"Device '{device_name}' not found", "available": names}, ensure_ascii=False))
            sys.exit(4)
        if len(matched) > 1:
            print(json.dumps({"choose_device": [{"id": d["id"], "name": d["name"]} for d in matched]}, ensure_ascii=False))
            sys.exit(3)
        device_id = matched[0]["id"]

    if device_id is None:
        # No device specified — if only one device exists, auto-select
        url = f"{BASE_URL}/api/v1.Device/index"
        resp = http_request("GET", url, headers=make_headers(api_key))
        devices_list = resp.get("data", [])
        if len(devices_list) == 0:
            print(json.dumps({"error": "No devices found"}, ensure_ascii=False))
            sys.exit(2)
        if len(devices_list) > 1:
            print(json.dumps({"choose_device": [{"id": d["id"], "name": d["name"]} for d in devices_list]}, ensure_ascii=False))
            sys.exit(3)
        device_id = devices_list[0]["id"]

    url = f"{BASE_URL}/api/v1.Realtime/index?id={device_id}"
    resp = http_request("GET", url, headers=make_headers(api_key))
    if resp.get("code") != 200:
        print(json.dumps({"error": resp.get("msg", "Unknown error"), "code": resp.get("code")}, ensure_ascii=False))
        sys.exit(2)
    payload = resp.get("data", {})
    sensor_raw = payload.get("data") or {}
    output = {
        "device_id": device_id,
        "device_name": payload.get("device_name"),
        "online": payload.get("online"),
        "sensors": format_sensor_data(sensor_raw),
        "_raw": sensor_raw,
    }
    print(json.dumps(output, ensure_ascii=False))


def action_history(api_key: str, device_id: Optional[int], device_name: Optional[str], range_: str) -> None:
    # Resolve device_id
    if device_id is None and device_name:
        url = f"{BASE_URL}/api/v1.Device/index"
        resp = http_request("GET", url, headers=make_headers(api_key))
        devices_list = resp.get("data", [])
        matched = [d for d in devices_list if d.get("name") == device_name]
        if not matched:
            print(json.dumps({"error": f"Device '{device_name}' not found", "available": [d["name"] for d in devices_list]}, ensure_ascii=False))
            sys.exit(4)
        device_id = matched[0]["id"]

    if device_id is None:
        print(json.dumps({"error": "Provide --device-id or --device-name"}, ensure_ascii=False))
        sys.exit(2)

    url = f"{BASE_URL}/api/v1.History/index?id={device_id}&range={range_}"
    resp = http_request("GET", url, headers=make_headers(api_key))
    if resp.get("code") != 200:
        print(json.dumps({"error": resp.get("msg", "Unknown error"), "code": resp.get("code")}, ensure_ascii=False))
        sys.exit(2)
    points = resp.get("data", [])
    print(json.dumps({"device_id": device_id, "range": range_, "count": len(points), "data": points}, ensure_ascii=False))


def action_shortcode(code: str) -> None:
    # Step 1: verify shortcode exists
    url = f"{BASE_URL}/api/v1.Shortcode/read?code={urllib.parse.quote(code)}"
    resp = http_request("GET", url, headers={"Accept": "application/json"})
    if resp.get("code") != 200:
        print(json.dumps({"error": "Shortcode not found or device is private", "code": resp.get("code")}, ensure_ascii=False))
        sys.exit(4)
    device_info = resp.get("data", {}).get("device", {})
    online = resp.get("data", {}).get("online", False)

    # Step 2: get public sensor data
    data_url = f"{BASE_URL}/api/v1.Shortcode/data?code={urllib.parse.quote(code)}"
    data_resp = http_request("GET", data_url, headers={"Accept": "application/json"})
    sensor_raw = {}
    if data_resp.get("code") == 200:
        sensor_raw = data_resp.get("data", {}).get("data") or {}

    output = {
        "shortcode": code,
        "device_name": device_info.get("name"),
        "model": device_info.get("model"),
        "online": online,
        "sensors": format_sensor_data(sensor_raw),
        "_raw": sensor_raw,
    }
    print(json.dumps(output, ensure_ascii=False))


# ─────────────────────────────── main ───────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="WittIoT weather station query skill.")
    parser.add_argument("--action", choices=["devices", "realtime", "history", "shortcode"],
                        default="realtime", help="Action to perform (default: realtime)")
    parser.add_argument("--api-key", default=None, help="WittIoT API Key (witt_sk_xxxx)")
    parser.add_argument("--device-id", type=int, default=None, help="Device ID")
    parser.add_argument("--device-name", default=None, help="Device name (partial match not supported)")
    parser.add_argument("--range", dest="range_", default="24h", choices=["24h", "7d", "30d"],
                        help="History range (default: 24h)")
    parser.add_argument("--code", default=None, help="Shortcode (6-char, for --action shortcode)")
    args = parser.parse_args()

    cfg = load_config()
    [REDACTED_SECRET], args.api_key)

    if args.action == "shortcode":
        if not args.code:
            print(json.dumps({"error": "Provide --code for shortcode action"}, ensure_ascii=False))
            sys.exit(2)
        action_shortcode(args.code)
        return

    # All other actions require auth
    if not [REDACTED_SECRET]
            "error": "Missing API key. Set WITTIOT_API_KEY env var, pass --api-key, or add to config.json",
            "hint": "Get your API key at https://wittiot.com/index/apikey"
        }, ensure_ascii=False))
        sys.exit(2)

    if args.action == "devices":
        action_devices(api_key)
    elif args.action == "realtime":
        action_realtime(api_key, args.device_id, args.device_name)
    elif args.action == "history":
        action_history(api_key, args.device_id, args.device_name, args.range_)


if __name__ == "__main__":
    main()


path: references/api-spec.md
content: # WittIoT Weather Station – API Specification

## Base URL

```
https://wittiot.com
```

## Authentication

- **API Key**：请求头 `X-[REDACTED_SECRET]`
- API Key 在 https://wittiot.com/index/apikey 创建
- 公开端点（Shortcode 查询）无需认证

---

## 1. 列出我的设备

- **Method:** GET
- **Path:** `/api/v1.Device/index`
- **Auth:** 必须
- **Response JSON (example):**

```json
{
  "code": 200,
  "msg": "ok",
  "data": [
    {
      "id": 1,
      "name": "Backyard Station",
      "model": "WS6900",
      "sn": "WS6900-000001",
      "online": true,
      "visibility": "public"
    },
    {
      "id": 2,
      "name": "Rooftop Station",
      "model": "WS3900",
      "sn": "WS3900-000002",
      "online": false,
      "visibility": "private"
    }
  ]
}
```

**Devices path:** `data`
**Device fields:** `id`, `name`, `model`, `sn`, `online`, `visibility`

---

## 2. 获取实时传感器数据

- **Method:** GET
- **Path:** `/api/v1.Realtime/index`
- **Auth:** 必须（如需访问私有设备）；`?scope=public` 时无需认证但设备须为公开
- **Params:**
  - `id` (int, required) — 设备 ID
  - `scope` (string, optional) — `public`：公开数据，不需要认证
- **Response JSON (example):**

```json
{
  "code": 200,
  "msg": "ok",
  "data": {
    "device_id": 1,
    "device_name": "Backyard Station",
    "online": true,
    "data": {
      "temperature": 23.5,
      "humidity": 68.2,
      "pressure": 1013.2,
      "light": 45.3,
      "wind_speed": 3.2,
      "wind_gust": 5.1,
      "wind_direction": 270,
      "rainfall": 0.0
    }
  }
}
```

**Data path:** `data.data`
**Online path:** `data.online`
**Device name path:** `data.device_name`

传感器字段说明：

| 字段 | 单位 | 说明 |
|------|------|------|
| `temperature` | °C | 温度 |
| `humidity` | %RH | 相对湿度 |
| `pressure` | hPa | 气压 |
| `light` | Klux | 光照强度 |
| `wind_speed` | m/s | 风速 |
| `wind_gust` | m/s | 阵风风速 |
| `wind_direction` | ° | 风向（0=北，90=东，180=南，270=西） |
| `rainfall` | mm | 降雨量 |

---

## 3. 获取历史数据

- **Method:** GET
- **Path:** `/api/v1.History/index`
- **Auth:** 必须
- **Params:**
  - `id` (int, required) — 设备 ID
  - `range` (string) — `24h` / `7d` / `30d`（默认 `24h`）
  - `type` (string) — `minute` / `daily` / `cmd`
- **Response JSON (example):**

```json
{
  "code": 200,
  "msg": "ok",
  "data": [
    {
      "timestamp": 1743000000,
      "temperature": 22.1,
      "humidity": 65.0,
      "pressure": 1012.8
    }
  ]
}
```

---

## 4. 通过短码查询设备信息（公开，无需认证）

- **Method:** GET
- **Path:** `/api/v1.Shortcode/read`
- **Auth:** 无需
- **Params:**
  - `code` (string, required) — 6字符短码（印在设备标签上）
- **Response JSON (example):**

```json
{
  "code": 200,
  "msg": "ok",
  "data": {
    "device": {
      "id": 1,
      "name": "Backyard Station",
      "model": "WS6900",
      "visibility": "public"
    },
    "online": true
  }
}
```

---

## 5. 通过短码获取公开实时数据（公开，无需认证）

- **Method:** GET
- **Path:** `/api/v1.Shortcode/data`
- **Auth:** 无需
- **Params:**
  - `code` (string, required) — 6字符短码
- **Response JSON (example):**

```json
{
  "code": 200,
  "msg": "ok",
  "data": {
    "online": true,
    "data": {
      "temperature": 23.5,
      "humidity": 68.2,
      "pressure": 1013.2,
      "wind_speed": 3.2,
      "rainfall": 0.0
    }
  }
}
```

---

## 错误码

| code | 说明 |
|------|------|
| 200 | 成功 |
| 201 | 创建成功 |
| 400 | 参数错误 |
| 401 | 未认证或 API Key 无效 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 409 | 冲突（如设备已绑定） |


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