Initial commit: Synology DSM API skill

- Add synology_api.py: DSM API client library
- Add synology_api_login.py: CLI tool for login and system info
- Add SKILL.md: Skill definition with usage examples
- Add CLAUDE.md: Developer documentation
- Add references/: Example configs and usage guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:55:43 +08:00
commit 321456d4e6
8 changed files with 1046 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""
Synology DSM API 自動登入 CLI使用 synology_api.py
預設:
DS_URL=https://syno.lotimmy.com/
DS_USER, DS_PASSWORD 從環境變數或 .env 讀取
用法:
python3 scripts/synology_api_login.py
python3 scripts/synology_api_login.py --system-info
python3 scripts/synology_api_login.py --system-info --summary
python3 scripts/synology_api_login.py --system-info --json-compact
輸出:
- tmp/synology_api_login_result.json (回應摘要)
- tmp/synology_session.json (sid 與 cookie)
- tmp/synology_system_info.json (System Information--system-info 時)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Callable
HOSTS_FILE = Path(__file__).resolve().parents[1] / "references" / "hosts.json"
from synology_api import DSMApiClient, load_dotenv
load_dotenv()
DEFAULT_URL = os.getenv("DS_URL", "https://syno.lotimmy.com/")
DEFAULT_USER = os.getenv("DS_USER", "")
DEFAULT_PASSWORD = os.getenv("DS_PASSWORD", "")
OUT_RESULT = os.getenv("DS_LOGIN_RESULT", "tmp/synology_api_login_result.json")
OUT_SESSION = os.getenv("DS_SESSION_FILE", "tmp/synology_session.json")
OUT_SYSINFO = os.getenv("DS_SYSINFO_FILE", "tmp/synology_system_info.json")
def load_hosts() -> dict[str, Any]:
if not HOSTS_FILE.exists():
return {}
try:
return json.loads(HOSTS_FILE.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return {}
def resolve_profile(profile_name: str) -> tuple[str, str, str]:
hosts = load_hosts()
profile = hosts.get(profile_name)
if not isinstance(profile, dict):
raise SystemExit(f"[x] 找不到 profile{profile_name}(檔案:{HOSTS_FILE}")
url = str(profile.get("url", "")).strip()
user = str(profile.get("user", "")).strip()
password = str(profile.get("password", "")).strip()
password_env = str(profile.get("password_env", "")).strip()
if not password and password_env:
password = os.getenv(password_env, "").strip()
if not url or not user or not password:
raise SystemExit(f"[x] profile {profile_name} 缺少 url/user/password或 password_env 未設定)")
return url, user, password
def _find_key(obj: Any, keys: tuple[str, ...]) -> Any:
if isinstance(obj, dict):
for k, v in obj.items():
lk = k.lower()
if any(x in lk for x in keys):
return v
for v in obj.values():
hit = _find_key(v, keys)
if hit is not None:
return hit
elif isinstance(obj, list):
for item in obj:
hit = _find_key(item, keys)
if hit is not None:
return hit
return None
def _to_int(value: Any) -> int | None:
try:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
v = str(value).strip()
if not v:
return None
return int(float(v))
except (TypeError, ValueError):
return None
def _to_gib(value: int | None) -> float | None:
if value is None:
return None
return round(value / (1024 ** 3), 2)
def _build_compact_summary(bundle: dict[str, Any]) -> dict[str, Any]:
sysinfo = bundle.get("system_info", {})
util = bundle.get("utilization", {})
vols = bundle.get("volumes", {})
hms_disk = bundle.get("storage_hms_disk", {})
storage_cgi = bundle.get("storage_cgi_storage", {})
network_ethernet = bundle.get("network_ethernet", {})
sdata = sysinfo.get("response", {}).get("data", {})
udata = util.get("response", {}).get("data", {})
vdata = vols.get("response", {}).get("data", {})
hdata = hms_disk.get("response", {}).get("data", {})
scdata = storage_cgi.get("response", {}).get("data", {})
ndata = network_ethernet.get("response", {}).get("data", [])
if not ndata and isinstance(network_ethernet.get("data"), list):
ndata = network_ethernet.get("data", [])
cpu_obj = udata.get("cpu", {}) if isinstance(udata, dict) else {}
cpu_usage_pct = None
if isinstance(cpu_obj, dict):
cpu_usage_pct = cpu_obj.get("user_load", 0) + cpu_obj.get("system_load", 0) + cpu_obj.get("other_load", 0)
mem_obj = udata.get("memory", {}) if isinstance(udata, dict) else {}
mem_usage_pct = mem_obj.get("real_usage") if isinstance(mem_obj, dict) else None
volume_obj = vdata.get("volumes", vdata.get("volume", vdata))
if not volume_obj:
volume_obj = scdata.get("volumes", []) if isinstance(scdata, dict) else []
if not volume_obj:
volume_obj = udata.get("space", {}).get("volume", []) if isinstance(udata, dict) else []
volumes: list[str] = []
volume_usage: dict[str, Any] = {}
disk_size_usage: dict[str, Any] = {}
if isinstance(volume_obj, list) and volume_obj:
for v in volume_obj:
if not isinstance(v, dict):
continue
name = (
v.get("display_name")
or v.get("name")
or v.get("volume_path")
or v.get("path")
or (f"volume{v.get('num_id')}" if v.get('num_id') is not None else None)
or v.get("device")
or v.get("id")
)
if not name:
continue
volumes.append(name)
usage = v.get("utilization")
if usage is not None:
volume_usage[name] = {
"utilization_pct": usage,
"read_byte": v.get("read_byte"),
"write_byte": v.get("write_byte"),
}
size_obj = v.get("size") if isinstance(v.get("size"), dict) else {}
total_bytes = (
v.get("total")
or v.get("total_size")
or v.get("size_total")
or v.get("capacity")
or size_obj.get("total")
)
used_bytes = (
v.get("used")
or v.get("used_size")
or v.get("size_used")
or v.get("used_space")
or size_obj.get("used")
)
free_bytes = (
v.get("free")
or v.get("free_size")
or v.get("size_free")
or v.get("available")
or size_obj.get("free")
)
total_i = _to_int(total_bytes)
used_i = _to_int(used_bytes)
free_i = _to_int(free_bytes)
if free_i is None and total_i is not None and used_i is not None:
free_i = max(total_i - used_i, 0)
if total_i is not None or used_i is not None or free_i is not None:
disk_size_usage[name] = {
"total_bytes": total_i,
"used_bytes": used_i,
"free_bytes": free_i,
"total_gib": _to_gib(total_i),
"used_gib": _to_gib(used_i),
"free_gib": _to_gib(free_i),
}
elif isinstance(volume_obj, dict) and volume_obj:
volumes = list(volume_obj.keys())
disk_health = hdata.get("disk", hdata.get("disks", hdata))
if not disk_health:
disk_health = {}
for d in scdata.get("disks", []) if isinstance(scdata, dict) else []:
if not isinstance(d, dict):
continue
name = d.get("name") or d.get("device") or d.get("id")
if not name:
continue
vendor = d.get("vendor")
if isinstance(vendor, str):
vendor = vendor.strip()
disk_health[name] = {
"status": d.get("status"),
"smart_status": d.get("smart_status"),
"temp_c": d.get("temp"),
"model": d.get("model"),
"vendor": vendor,
"disk_type": d.get("diskType"),
}
if isinstance(disk_health, dict):
for _, info in disk_health.items():
if isinstance(info, dict) and isinstance(info.get("vendor"), str):
info["vendor"] = info["vendor"].strip()
primary_ip = None
if isinstance(ndata, list):
for nic in ndata:
if not isinstance(nic, dict):
continue
if nic.get("ifname") == "eth0" and nic.get("ip"):
primary_ip = nic.get("ip")
break
if primary_ip is None:
for nic in ndata:
if isinstance(nic, dict) and nic.get("ip") and nic.get("ip") != "":
primary_ip = nic.get("ip")
break
serial = sdata.get("serial")
result = {
"model": sdata.get("model"),
"serial": serial,
"ip": primary_ip,
"dsm_version": sdata.get("firmware_ver"),
"cpu_usage_pct": cpu_usage_pct,
"cpu_clock_mhz": sdata.get("cpu_clock_speed"),
"memory_total_mb": sdata.get("ram_size"),
"memory_usage_pct": mem_usage_pct,
"temperature_c": sdata.get("sys_temp", _find_key(sdata, ("temperature", "temp"))),
"volumes": volumes,
"disk_size_usage": disk_size_usage,
"disk_health": disk_health,
"time": sdata.get("time"),
"uptime": sdata.get("up_time"),
}
return result
def _safe_call(fn: Callable[[], dict[str, Any]], name: str) -> dict[str, Any]:
try:
return fn()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"error": f"{name} failed: {exc}",
"response": {"data": {}},
}
def _load_cached_sid(session_file: str, expected_base: str) -> str:
p = Path(session_file)
if not p.exists():
return ""
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return ""
base = str(data.get("base", "")).strip().rstrip("/")
sid = str(data.get("sid", "")).strip()
if not sid:
return ""
if base and base != expected_base.rstrip("/"):
return ""
return sid
def _is_sid_valid(client: DSMApiClient) -> bool:
probe = _safe_call(client.get_system_info, "get_system_info")
return bool(probe.get("success") is True)
def _print_summary(bundle: dict[str, Any]) -> None:
c = _build_compact_summary(bundle)
vol_summary = ", ".join(c.get("volumes", [])) if c.get("volumes") else "N/A"
print("[SUMMARY] System Information")
print(f"- CPU 使用率: {str(c.get('cpu_usage_pct')) + '%' if c.get('cpu_usage_pct') is not None else 'N/A'}")
print(f"- CPU 頻率: {str(c.get('cpu_clock_mhz')) + ' MHz' if c.get('cpu_clock_mhz') is not None else 'N/A'}")
print(f"- 記憶體(總量): {str(c.get('memory_total_mb')) + ' MB' if c.get('memory_total_mb') is not None else 'N/A'}")
print(f"- 記憶體(使用): {str(c.get('memory_usage_pct')) + '%' if c.get('memory_usage_pct') is not None else 'N/A'}")
print(f"- 溫度: {str(c.get('temperature_c')) + '°C' if c.get('temperature_c') is not None else 'N/A'}")
print(f"- Volume: {vol_summary}")
size_usage = c.get("disk_size_usage", {})
if isinstance(size_usage, dict) and size_usage:
print("- 容量:")
for vname, vinfo in size_usage.items():
if not isinstance(vinfo, dict):
continue
t = vinfo.get("total_gib")
u = vinfo.get("used_gib")
f = vinfo.get("free_gib")
print(f" - {vname}: 總 {t} GiB / 已用 {u} GiB / 可用 {f} GiB")
def main() -> int:
parser = argparse.ArgumentParser(description="DSM API login (no Playwright)")
parser.add_argument("--profile", help="主機設定名稱(讀 references/hosts.json")
parser.add_argument("--url", default=DEFAULT_URL, help="DSM URL")
parser.add_argument("--user", default=DEFAULT_USER, help="帳號(預設 DS_USER")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="密碼(預設 DS_PASSWORD")
parser.add_argument("--system-info", action="store_true", help="登入後抓取 System Information")
parser.add_argument("--summary", action="store_true", help="搭配 --system-info 顯示人類可讀摘要")
parser.add_argument("--json-compact", action="store_true", help="搭配 --system-info 輸出精簡 JSON")
args = parser.parse_args()
if args.profile:
args.url, args.user, args.password = resolve_profile(args.profile)
client = DSMApiClient(base_url=args.url)
Path("tmp").mkdir(parents=True, exist_ok=True)
used_cached_sid = False
cached_sid = _load_cached_sid(OUT_SESSION, client.base_url)
if cached_sid:
client.sid = cached_sid
if _is_sid_valid(client):
used_cached_sid = True
result_obj: dict[str, Any]
if used_cached_sid:
result_obj = {
"success": True,
"meta": {
"base": client.base_url,
"source": "cached_sid",
"session_file": OUT_SESSION,
},
"data": {"sid": client.sid},
}
print("[✓] DSM API Session 可用(沿用既有 SID")
print(f"[i] SID: {client.sid[:8]}... (來源 {OUT_SESSION})")
else:
if not args.user or not args.password:
print("[x] 缺少帳密:請設定 DS_USER / DS_PASSWORD 或傳 --user/--password", file=sys.stderr)
return 2
result = client.login(user=args.user, password=args.password)
result_obj = {
"success": result.success,
"meta": result.meta,
"data": result.data,
}
if not result.success:
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
print("[x] DSM API 登入失敗", file=sys.stderr)
print(f"[i] 詳細結果:{OUT_RESULT}", file=sys.stderr)
return 1
session_obj = client.export_session(result)
Path(OUT_SESSION).write_text(json.dumps(session_obj, ensure_ascii=False, indent=2), encoding="utf-8")
sid = session_obj.get("sid", "")
print("[✓] DSM API 登入成功")
print(f"[i] SID: {sid[:8]}... (已寫入 {OUT_SESSION})")
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
if args.system_info:
sysinfo = _safe_call(client.get_system_info, "get_system_info")
util = _safe_call(client.get_utilization, "get_utilization")
vols = _safe_call(client.get_volumes, "get_volumes")
hms_disk = _safe_call(client.get_storage_hms_disk, "get_storage_hms_disk")
storage_cgi = _safe_call(client.get_storage_cgi_storage, "get_storage_cgi_storage")
network_ethernet = _safe_call(
lambda: client.api_get(
"/webapi/entry.cgi",
{"api": "SYNO.Core.Network.Ethernet", "version": "1", "method": "list"},
),
"get_network_ethernet",
)
bundle = {
"system_info": sysinfo,
"utilization": util,
"volumes": vols,
"storage_hms_disk": hms_disk,
"storage_cgi_storage": storage_cgi,
"network_ethernet": network_ethernet,
}
Path(OUT_SYSINFO).write_text(json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8")
if sysinfo.get("success"):
print(f"[i] System Information 已寫入:{OUT_SYSINFO}")
if args.summary:
_print_summary(bundle)
if args.json_compact:
print(json.dumps(_build_compact_summary(bundle), ensure_ascii=False))
else:
print(f"[!] System Information 取得失敗,已寫入:{OUT_SYSINFO}")
print(f"[i] 詳細結果:{OUT_RESULT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())