Sync from Mac mini main: preserve full-app-path fix (/System/Applications/), reminders assignment hints, deco_ha_bridge for clients, plus image tools, plus system speech API check
Mac mini is on macOS 26.5.2 Mac16,10 M4 - SpeechAnalyzer available
- Calendar/Contacts/Notes/Reminders use full /System/Applications/*.app path (more reliable)
- Reminders now returns assignment {assignee, source, available} with fallback to (Name) in title
- deco.js uses deco_ha_bridge.py for clients action (more reliable than old bridge)
- Added deco_ha_bridge.py (13KB RSA/AES Deco HA client)
- Added system_get_info + system_speech_api_status (verifies SpeechAnalyzer on macOS 26+)
- Preserved codex-image, gemini-image, gemini-chrome-prompt tools
- Updated README with all 23 tools + macOS 26 note + benchmark ref
This commit is contained in:
@@ -14,17 +14,25 @@ and stays on the local machine.
|
|||||||
| `calendar_list_calendars` | List calendar indexes, names, and write capability |
|
| `calendar_list_calendars` | List calendar indexes, names, and write capability |
|
||||||
| `calendar_list_events` | List events in an ISO-8601 time window from the focused `Home` calendar |
|
| `calendar_list_events` | List events in an ISO-8601 time window from the focused `Home` calendar |
|
||||||
| `calendar_create_event` | Create an event in the focused `Home` calendar |
|
| `calendar_create_event` | Create an event in the focused `Home` calendar |
|
||||||
| `reminders_list_lists` | List reminder lists |
|
| `reminders_list_lists` | List reminder lists with account context and assignment metadata availability |
|
||||||
| `reminders_list` | List reminders |
|
| `reminders_list` | List reminders, including assignment details or assignment hints for shared-list reminders |
|
||||||
| `reminders_create` | Create a reminder |
|
| `reminders_create` | Create a reminder |
|
||||||
| `contacts_search` | Search contact names and organizations without disclosing contact methods |
|
| `contacts_search` | Search contact names and organizations without disclosing contact methods |
|
||||||
| `contacts_read` | Read phone and email details for one selected contact |
|
| `contacts_read` | Read phone and email details for one selected contact |
|
||||||
| `contacts_create` | Create a contact with optional email and phone details |
|
| `contacts_create` | Create a contact with optional email and phone details |
|
||||||
| `deco_get_config_status` | Show Deco connection config without revealing the password |
|
| `deco_get_config_status` | Show Deco connection config without revealing the password |
|
||||||
| `deco_get_overview` | Read TP-Link Deco overview stats and firmware |
|
| `deco_get_overview` | Read TP-Link Deco overview stats and firmware |
|
||||||
| `deco_list_clients` | List online Deco clients and current traffic speeds |
|
| `deco_list_clients` | List online Deco clients, current traffic speeds, and linked mesh node when available |
|
||||||
| `deco_get_ipv4_status` | Read WAN/LAN IPv4 status |
|
| `deco_get_ipv4_status` | Read WAN/LAN IPv4 status |
|
||||||
| `deco_get_firmware` | Read Deco model and firmware version |
|
| `deco_get_firmware` | Read Deco model and firmware version |
|
||||||
|
| `system_get_info` | Get macOS version (sw_vers), hardware model, and SpeechAnalyzer availability |
|
||||||
|
| `system_speech_api_status` | Full check for Apple SpeechAnalyzer/SpeechTranscriber (macOS 26+) |
|
||||||
|
| `codex_image_get_config_status` | Show local Codex CLI image generation config |
|
||||||
|
| `codex_image_generate` | Generate an image with this Mac's Codex CLI and save it locally |
|
||||||
|
| `gemini_image_get_config_status` | Show Gemini image generation config without revealing the API key |
|
||||||
|
| `gemini_image_generate` | Generate an image with the Gemini API and save it locally |
|
||||||
|
| `gemini_chrome_prompt_get_config_status` | Show config for the Codex Chrome-skill Gemini image prompt builder |
|
||||||
|
| `gemini_chrome_prompt_build` | Build a ready-to-run Codex prompt for Gemini web-app image generation |
|
||||||
|
|
||||||
There are no destructive tools in the initial server.
|
There are no destructive tools in the initial server.
|
||||||
|
|
||||||
@@ -33,6 +41,16 @@ event-rich `Home` calendar discovered during setup (`calendarIndex: 2`) and
|
|||||||
verifies the selected index is still named `Home` before operating on it.
|
verifies the selected index is still named `Home` before operating on it.
|
||||||
Calendar selector parameters remain available as advanced overrides.
|
Calendar selector parameters remain available as advanced overrides.
|
||||||
|
|
||||||
|
Reminder assignment data is exposed on `reminders_list` as an `assignment`
|
||||||
|
object. Apple Reminders automation does not currently publish shared-list
|
||||||
|
participant metadata directly, so the tool first checks for any native assignee
|
||||||
|
field macOS exposes and then falls back to assignment hints embedded in the
|
||||||
|
reminder title or notes, such as `(Alicia)` or `Captured 2026-05-18, Alicia;`.
|
||||||
|
|
||||||
|
**macOS 26 note:** This Mac is on macOS 26.5.2 (Mac16,10 M4) — so `system_speech_api_status`
|
||||||
|
confirms SpeechAnalyzer/SpeechTranscriber is available. Apple's new engine beats Whisper
|
||||||
|
Small 2.12% vs 3.74% WER per Inscribe benchmark (2026-07-13).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Requires macOS and Node.js 20 or newer.
|
Requires macOS and Node.js 20 or newer.
|
||||||
@@ -86,7 +104,7 @@ For a trusted local-network harness such as a Raspberry Pi, set
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
URL: http://<mac-lan-ip>:7331/mcp
|
URL: http://<mac-lan-ip>:7331/mcp
|
||||||
Authorization: Bearer <MACMINI_MCP_TOKEN>
|
Authorization: Bearer <MACMI...KEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart after changing `.env`:
|
Restart after changing `.env`:
|
||||||
@@ -96,8 +114,28 @@ npm run service:restart
|
|||||||
```
|
```
|
||||||
|
|
||||||
For TP-Link Deco tools, install Python dependencies with `npm run
|
For TP-Link Deco tools, install Python dependencies with `npm run
|
||||||
python:install`, then set `DECO_HOST`, `DECO_USERNAME`, `DECO_PASSWORD`, and
|
python:install`, then set `DECO_HOST`, `DECO_USERNAME=admin`, `DECO_PASSWORD`,
|
||||||
optionally `DECO_VERIFY_SSL=false` in `.env`.
|
and optionally `DECO_VERIFY_SSL=false` in `.env`.
|
||||||
|
|
||||||
|
For Codex image generation, make sure the Mac is logged in with `codex login`.
|
||||||
|
Generated files are saved to `generated-images/` by default; override this with
|
||||||
|
`CODEX_IMAGE_OUTPUT_DIR`. Set `CODEX_IMAGE_MODEL` or `CODEX_IMAGE_TIMEOUT_MS` or
|
||||||
|
`CODEX_CLI_PATH` to the absolute `codex` path.
|
||||||
|
|
||||||
|
For Gemini image generation, set `GEMINI_API_KEY` in `.env`. Generated files are
|
||||||
|
saved to `generated-images/` by default; override this with
|
||||||
|
`GEMINI_IMAGE_OUTPUT_DIR`. The Gemini image tool calls the Gemini API directly
|
||||||
|
and does not expose browser navigation, page inspection, or screenshot tools.
|
||||||
|
|
||||||
|
For Gemini image generation through the signed-in Chrome web app, use
|
||||||
|
`gemini_chrome_prompt_build` to create a ready-to-run Codex prompt. The MCP
|
||||||
|
service does not control Chrome directly; the Chrome skill is only available
|
||||||
|
inside an active Codex session. The generated prompt verifies the Chrome profile
|
||||||
|
name is `ReynaFamilyBot`, opens only `https://gemini.google.com/app`, submits the
|
||||||
|
image prompt, downloads the generated image, and copies it to
|
||||||
|
`generated-images/`. Override the expected Chrome profile with
|
||||||
|
`GEMINI_CHROME_PROFILE_NAME` and the output directory with
|
||||||
|
`GEMINI_CHROME_IMAGE_OUTPUT_DIR`.
|
||||||
|
|
||||||
For a harness that launches stdio servers, use:
|
For a harness that launches stdio servers, use:
|
||||||
|
|
||||||
@@ -120,7 +158,7 @@ to control under **System Settings > Privacy & Security > Automation**.
|
|||||||
|
|
||||||
The HTTP service binds to `127.0.0.1` by default. When configured to bind to a
|
The HTTP service binds to `127.0.0.1` by default. When configured to bind to a
|
||||||
LAN address, it refuses to start without `MACMINI_MCP_TOKEN`; clients must send
|
LAN address, it refuses to start without `MACMINI_MCP_TOKEN`; clients must send
|
||||||
`Authorization: Bearer <token>`. This is HTTP bearer authentication on your
|
`Authorization: Bearer *** This is HTTP bearer authentication on your
|
||||||
local network, not encrypted transport. Use it only on a trusted LAN or put it
|
local network, not encrypted transport. Use it only on a trusted LAN or put it
|
||||||
behind a private encrypted network such as a VPN.
|
behind a private encrypted network such as a VPN.
|
||||||
|
|
||||||
@@ -133,3 +171,10 @@ npm run dev
|
|||||||
|
|
||||||
The MCP transport follows the official TypeScript SDK Streamable HTTP server
|
The MCP transport follows the official TypeScript SDK Streamable HTTP server
|
||||||
approach: [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
|
approach: [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
|
||||||
|
|
||||||
|
## Image generation
|
||||||
|
|
||||||
|
This server exposes Codex-backed and Gemini-backed image generation tools.
|
||||||
|
Gemini image generation uses the Gemini API directly, not browser automation.
|
||||||
|
Image Playground was tried and removed because the macOS app does not expose a
|
||||||
|
scriptable prompt-to-file action through AppleScript or Shortcuts/App Intents.
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from Crypto.Cipher import AES, PKCS1_v1_5
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Util.Padding import pad, unpad
|
||||||
|
from requests import RequestException
|
||||||
|
|
||||||
|
|
||||||
|
AES_KEY_BYTES = 16
|
||||||
|
MIN_AES_KEY = 10 ** (AES_KEY_BYTES - 1)
|
||||||
|
MAX_AES_KEY = (10**AES_KEY_BYTES) - 1
|
||||||
|
PKCS1_V1_5_HEADER_BYTES = 11
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file():
|
||||||
|
path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env")
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as env_file:
|
||||||
|
for line in env_file:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key, value.strip().strip("\"'"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def default_gateway():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/sbin/route", "-n", "get", "default"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
if key.strip() == "gateway":
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def config():
|
||||||
|
load_env_file()
|
||||||
|
host = os.environ.get("DECO_HOST") or default_gateway()
|
||||||
|
password = os.environ.get("DECO_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("DECO_PASSWORD must be set in .env.")
|
||||||
|
if not host:
|
||||||
|
raise RuntimeError("DECO_HOST must be set in .env; default gateway detection failed.")
|
||||||
|
if not host.startswith(("http://", "https://")):
|
||||||
|
host = f"http://{host}"
|
||||||
|
verify_ssl = os.environ.get("DECO_VERIFY_SSL", "true").lower() not in {
|
||||||
|
"0",
|
||||||
|
"false",
|
||||||
|
"no",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"host": host.rstrip("/"),
|
||||||
|
"username": os.environ.get("DECO_USERNAME", "admin"),
|
||||||
|
"password": password,
|
||||||
|
"verify_ssl": verify_ssl,
|
||||||
|
"timeout": int(os.environ.get("DECO_TIMEOUT", "10")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def byte_len(n):
|
||||||
|
return (int(math.log2(n)) + 8) >> 3
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_encrypt(n, e, plaintext):
|
||||||
|
public_key = RSA.construct((n, e)).publickey()
|
||||||
|
encryptor = PKCS1_v1_5.new(public_key)
|
||||||
|
block_size = byte_len(n)
|
||||||
|
bytes_per_block = block_size - PKCS1_V1_5_HEADER_BYTES
|
||||||
|
encrypted_text = ""
|
||||||
|
for index in range(0, len(plaintext), bytes_per_block):
|
||||||
|
encrypted_text += encryptor.encrypt(plaintext[index:index + bytes_per_block]).hex()
|
||||||
|
return encrypted_text
|
||||||
|
|
||||||
|
|
||||||
|
def decode_name(value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return base64.b64decode(value).decode()
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_snake(value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
return " ".join(part.title() for part in value.split("_"))
|
||||||
|
|
||||||
|
|
||||||
|
def deco_name(device):
|
||||||
|
return (
|
||||||
|
decode_name(device.get("custom_nickname"))
|
||||||
|
or title_from_snake(device.get("nickname"))
|
||||||
|
or device.get("device_model")
|
||||||
|
or device.get("mac")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DecoApi:
|
||||||
|
def __init__(self, host, username, password, verify_ssl, timeout):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.aes_key = None
|
||||||
|
self.aes_iv = None
|
||||||
|
self.password_rsa_n = None
|
||||||
|
self.password_rsa_e = None
|
||||||
|
self.sign_rsa_n = None
|
||||||
|
self.sign_rsa_e = None
|
||||||
|
self.seq = None
|
||||||
|
self.stok = None
|
||||||
|
self.cookie = None
|
||||||
|
|
||||||
|
def generate_aes_key_and_iv(self):
|
||||||
|
self.aes_key = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
|
||||||
|
self.aes_iv = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
|
||||||
|
|
||||||
|
def post(self, context, path, params, data):
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
cookies = {}
|
||||||
|
if self.cookie:
|
||||||
|
name, _, value = self.cookie.partition("=")
|
||||||
|
if name and value:
|
||||||
|
cookies[name] = value
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.host}{path}",
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
except RequestException:
|
||||||
|
response = self.curl_post(path, params, data, cookies)
|
||||||
|
if response.status_code == 403:
|
||||||
|
self.clear_auth()
|
||||||
|
raise RuntimeError(f"{context}: forbidden")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
for cookie in response.headers.get("Set-Cookie", "").split(","):
|
||||||
|
match = re.search(r"(sysauth=[A-Za-z0-9]+)", cookie)
|
||||||
|
if match:
|
||||||
|
self.cookie = match.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
error_code = result.get("error_code")
|
||||||
|
if error_code not in (None, "", 0):
|
||||||
|
raise RuntimeError(f"{context}: response error_code={error_code}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def curl_post(self, path, params, data, cookies):
|
||||||
|
url = f"{self.host}{path}"
|
||||||
|
if params:
|
||||||
|
query = "&".join(f"{key}={quote_plus(str(value))}" for key, value in params.items())
|
||||||
|
url = f"{url}?{query}"
|
||||||
|
command = [
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-i",
|
||||||
|
"--connect-timeout",
|
||||||
|
str(self.timeout),
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"--data-raw",
|
||||||
|
data,
|
||||||
|
]
|
||||||
|
if not self.verify_ssl:
|
||||||
|
command.insert(2, "-k")
|
||||||
|
if cookies:
|
||||||
|
command.extend(["-H", "Cookie: " + "; ".join(f"{k}={v}" for k, v in cookies.items())])
|
||||||
|
|
||||||
|
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=self.timeout + 5)
|
||||||
|
head, separator, body = result.stdout.rpartition("\r\n\r\n")
|
||||||
|
if not separator:
|
||||||
|
head, _, body = result.stdout.rpartition("\n\n")
|
||||||
|
status_match = re.search(r"HTTP/\S+\s+(\d+)", head)
|
||||||
|
status = int(status_match.group(1)) if status_match else 200
|
||||||
|
response = requests.Response()
|
||||||
|
response.status_code = status
|
||||||
|
response._content = body.encode()
|
||||||
|
response.url = url
|
||||||
|
for line in head.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
response.headers[key.strip()] = value.strip()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def fetch_keys(self):
|
||||||
|
response = self.post(
|
||||||
|
"Fetch keys",
|
||||||
|
"/cgi-bin/luci/;stok=/login",
|
||||||
|
{"form": "keys"},
|
||||||
|
json.dumps({"operation": "read"}),
|
||||||
|
)
|
||||||
|
keys = response["result"]["password"]
|
||||||
|
self.password_rsa_n = int(keys[0], 16)
|
||||||
|
self.password_rsa_e = int(keys[1], 16)
|
||||||
|
|
||||||
|
def fetch_auth(self):
|
||||||
|
response = self.post(
|
||||||
|
"Fetch auth",
|
||||||
|
"/cgi-bin/luci/;stok=/login",
|
||||||
|
{"form": "auth"},
|
||||||
|
json.dumps({"operation": "read"}),
|
||||||
|
)
|
||||||
|
auth = response["result"]
|
||||||
|
self.sign_rsa_n = int(auth["key"][0], 16)
|
||||||
|
self.sign_rsa_e = int(auth["key"][1], 16)
|
||||||
|
self.seq = auth["seq"]
|
||||||
|
|
||||||
|
def encode_payload(self, payload):
|
||||||
|
payload_json = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
encrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).encrypt(
|
||||||
|
pad(payload_json, AES.block_size)
|
||||||
|
)
|
||||||
|
data = base64.b64encode(encrypted).decode()
|
||||||
|
sign = self.encode_sign(len(data))
|
||||||
|
return f"sign={sign}&data={quote_plus(data)}"
|
||||||
|
|
||||||
|
def encode_sign(self, data_len):
|
||||||
|
auth_hash = hashlib.md5(f"{self.username}{self.password}".encode()).hexdigest()
|
||||||
|
sign_text = (
|
||||||
|
f"k={self.aes_key.decode()}&i={self.aes_iv.decode()}&h={auth_hash}&s={self.seq + data_len}"
|
||||||
|
)
|
||||||
|
return rsa_encrypt(self.sign_rsa_n, self.sign_rsa_e, sign_text.encode())
|
||||||
|
|
||||||
|
def decrypt_data(self, context, data):
|
||||||
|
if not data:
|
||||||
|
self.clear_auth()
|
||||||
|
raise RuntimeError(f"{context}: empty data")
|
||||||
|
decrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).decrypt(
|
||||||
|
base64.b64decode(data)
|
||||||
|
)
|
||||||
|
return json.loads(unpad(decrypted, AES.block_size).decode())
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
if self.aes_key is None:
|
||||||
|
self.generate_aes_key_and_iv()
|
||||||
|
if self.password_rsa_n is None:
|
||||||
|
self.fetch_keys()
|
||||||
|
if self.seq is None:
|
||||||
|
self.fetch_auth()
|
||||||
|
encrypted_password = rsa_encrypt(
|
||||||
|
self.password_rsa_n,
|
||||||
|
self.password_rsa_e,
|
||||||
|
self.password.encode(),
|
||||||
|
)
|
||||||
|
response = self.post(
|
||||||
|
"Login",
|
||||||
|
"/cgi-bin/luci/;stok=/login",
|
||||||
|
{"form": "login"},
|
||||||
|
self.encode_payload({
|
||||||
|
"operation": "login",
|
||||||
|
"params": {"password": encrypted_password},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data = self.decrypt_data("Login", response["data"])
|
||||||
|
if data.get("error_code") != 0:
|
||||||
|
result = data.get("result") or {}
|
||||||
|
attempts = result.get("attemptsAllowed", "unknown")
|
||||||
|
raise RuntimeError(f"Login failed: error_code={data.get('error_code')}; attempts={attempts}")
|
||||||
|
self.stok = data["result"]["stok"]
|
||||||
|
if not self.cookie:
|
||||||
|
raise RuntimeError("Login succeeded but no sysauth cookie was returned.")
|
||||||
|
|
||||||
|
def clear_auth(self):
|
||||||
|
self.seq = None
|
||||||
|
self.stok = None
|
||||||
|
self.cookie = None
|
||||||
|
|
||||||
|
def call(self, context, section, form, payload):
|
||||||
|
if not self.stok or not self.cookie:
|
||||||
|
self.login()
|
||||||
|
response = self.post(
|
||||||
|
context,
|
||||||
|
f"/cgi-bin/luci/;stok={self.stok}/admin/{section}",
|
||||||
|
{"form": form},
|
||||||
|
self.encode_payload(payload),
|
||||||
|
)
|
||||||
|
data = self.decrypt_data(context, response["data"])
|
||||||
|
error_code = data.get("error_code") or data.get("errorcode")
|
||||||
|
if error_code:
|
||||||
|
raise RuntimeError(f"{context}: decoded error_code={error_code}")
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
def list_decos(self):
|
||||||
|
devices = self.call("List Devices", "device", "device_list", {"operation": "read"}).get("device_list", [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": deco_name(device),
|
||||||
|
"mac": device.get("mac"),
|
||||||
|
"ip": device.get("device_ip"),
|
||||||
|
"model": device.get("device_model"),
|
||||||
|
"hardwareVersion": device.get("hardware_ver"),
|
||||||
|
"firmwareVersion": device.get("software_ver"),
|
||||||
|
"role": device.get("role"),
|
||||||
|
"online": device.get("group_status") == "connected",
|
||||||
|
"connectionType": device.get("connection_type"),
|
||||||
|
}
|
||||||
|
for device in devices
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_clients_for_deco(self, deco):
|
||||||
|
clients = self.call(
|
||||||
|
f"List Clients {deco['mac']}",
|
||||||
|
"client",
|
||||||
|
"client_list",
|
||||||
|
{"operation": "read", "params": {"device_mac": deco["mac"]}},
|
||||||
|
).get("client_list", [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"hostname": decode_name(client.get("name")),
|
||||||
|
"mac": client.get("mac"),
|
||||||
|
"ip": client.get("ip"),
|
||||||
|
"connection": client.get("connection_type"),
|
||||||
|
"interface": client.get("interface"),
|
||||||
|
"upSpeed": client.get("up_speed"),
|
||||||
|
"downSpeed": client.get("down_speed"),
|
||||||
|
"active": client.get("online"),
|
||||||
|
"linkedDecoMac": deco.get("mac"),
|
||||||
|
"linkedDecoName": deco.get("name"),
|
||||||
|
"linkedDecoRole": deco.get("role"),
|
||||||
|
}
|
||||||
|
for client in clients
|
||||||
|
if client.get("online")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not config()["verify_ssl"]:
|
||||||
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
deco = DecoApi(**config())
|
||||||
|
decos = deco.list_decos()
|
||||||
|
clients = {}
|
||||||
|
for node in decos:
|
||||||
|
if not node.get("mac"):
|
||||||
|
continue
|
||||||
|
for client in deco.list_clients_for_deco(node):
|
||||||
|
clients[client["mac"]] = client
|
||||||
|
return {"decos": decos, "clients": list(clients.values())}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
print(json.dumps(run(), indent=2))
|
||||||
|
except Exception as err:
|
||||||
|
print(json.dumps({"error": str(err)}), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -7,7 +7,7 @@ export const FOCUS_CALENDAR = Object.freeze({
|
|||||||
|
|
||||||
const CALENDAR_LIST_SCRIPT = String.raw`
|
const CALENDAR_LIST_SCRIPT = String.raw`
|
||||||
function run() {
|
function run() {
|
||||||
const app = Application("Calendar");
|
const app = Application("/System/Applications/Calendar.app");
|
||||||
return JSON.stringify(app.calendars().map(function (calendar, index) {
|
return JSON.stringify(app.calendars().map(function (calendar, index) {
|
||||||
return {
|
return {
|
||||||
index: index,
|
index: index,
|
||||||
@@ -20,7 +20,7 @@ function run() {
|
|||||||
const EVENTS_LIST_SCRIPT = String.raw`
|
const EVENTS_LIST_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Calendar");
|
const app = Application("/System/Applications/Calendar.app");
|
||||||
const start = new Date(input.start);
|
const start = new Date(input.start);
|
||||||
const end = new Date(input.end);
|
const end = new Date(input.end);
|
||||||
const calendars = app.calendars();
|
const calendars = app.calendars();
|
||||||
@@ -61,7 +61,7 @@ function run(argv) {
|
|||||||
const EVENT_CREATE_SCRIPT = String.raw`
|
const EVENT_CREATE_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Calendar");
|
const app = Application("/System/Applications/Calendar.app");
|
||||||
const calendars = app.calendars();
|
const calendars = app.calendars();
|
||||||
let destination = null;
|
let destination = null;
|
||||||
let destinationIndex = null;
|
let destinationIndex = null;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function text(value) {
|
|||||||
|
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Contacts");
|
const app = Application("/System/Applications/Contacts.app");
|
||||||
const query = (input.query || "").toLowerCase();
|
const query = (input.query || "").toLowerCase();
|
||||||
const people = app.people();
|
const people = app.people();
|
||||||
const found = [];
|
const found = [];
|
||||||
@@ -41,7 +41,7 @@ function items(values) {
|
|||||||
|
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Contacts");
|
const app = Application("/System/Applications/Contacts.app");
|
||||||
const people = app.people();
|
const people = app.people();
|
||||||
for (let i = 0; i < people.length; i++) {
|
for (let i = 0; i < people.length; i++) {
|
||||||
const person = people[i];
|
const person = people[i];
|
||||||
@@ -64,7 +64,7 @@ function run(argv) {
|
|||||||
const CONTACTS_CREATE_SCRIPT = String.raw`
|
const CONTACTS_CREATE_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Contacts");
|
const app = Application("/System/Applications/Contacts.app");
|
||||||
const person = app.Person({
|
const person = app.Person({
|
||||||
firstName: input.firstName || "",
|
firstName: input.firstName || "",
|
||||||
lastName: input.lastName || "",
|
lastName: input.lastName || "",
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname;
|
const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname;
|
||||||
const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname;
|
const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname;
|
||||||
|
const HA_CLIENTS_BRIDGE = new URL("../../scripts/deco_ha_bridge.py", import.meta.url).pathname;
|
||||||
|
|
||||||
export async function getDecoStats(action) {
|
export async function getDecoStats(action) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(PYTHON, [BRIDGE, action], {
|
const args = action === "clients" ? [HA_CLIENTS_BRIDGE] : [BRIDGE, action];
|
||||||
timeout: 45_000,
|
const { stdout } = await execFileAsync(PYTHON, args, {
|
||||||
|
timeout: 90_000,
|
||||||
maxBuffer: 4 * 1024 * 1024,
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { plainTextToNoteHtml, runJxa } from "../apple-events.js";
|
|||||||
const NOTES_LIST_SCRIPT = String.raw`
|
const NOTES_LIST_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Notes");
|
const app = Application("/System/Applications/Notes.app");
|
||||||
const query = (input.query || "").toLowerCase();
|
const query = (input.query || "").toLowerCase();
|
||||||
const folderName = input.folder || "";
|
const folderName = input.folder || "";
|
||||||
const limit = input.limit;
|
const limit = input.limit;
|
||||||
@@ -41,7 +41,7 @@ function run(argv) {
|
|||||||
const NOTES_READ_SCRIPT = String.raw`
|
const NOTES_READ_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Notes");
|
const app = Application("/System/Applications/Notes.app");
|
||||||
const accounts = app.accounts();
|
const accounts = app.accounts();
|
||||||
for (let a = 0; a < accounts.length; a++) {
|
for (let a = 0; a < accounts.length; a++) {
|
||||||
const folders = accounts[a].folders();
|
const folders = accounts[a].folders();
|
||||||
@@ -69,7 +69,7 @@ function run(argv) {
|
|||||||
const NOTES_CREATE_SCRIPT = String.raw`
|
const NOTES_CREATE_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Notes");
|
const app = Application("/System/Applications/Notes.app");
|
||||||
const accounts = app.accounts();
|
const accounts = app.accounts();
|
||||||
let destination = null;
|
let destination = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,105 @@ import { dateFromInput, runJxa } from "../apple-events.js";
|
|||||||
|
|
||||||
const LISTS_SCRIPT = String.raw`
|
const LISTS_SCRIPT = String.raw`
|
||||||
function run() {
|
function run() {
|
||||||
const app = Application("Reminders");
|
const app = Application("/System/Applications/Reminders.app");
|
||||||
|
function stringValue(value) {
|
||||||
|
try {
|
||||||
|
return value === null || value === undefined ? null : String(value);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function accountName(list) {
|
||||||
|
try {
|
||||||
|
const container = list.container();
|
||||||
|
return stringValue(container.name ? container.name() : container);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return JSON.stringify(app.lists().map(function (list) {
|
return JSON.stringify(app.lists().map(function (list) {
|
||||||
return { id: String(list.id()), name: String(list.name()) };
|
return {
|
||||||
|
id: String(list.id()),
|
||||||
|
name: String(list.name()),
|
||||||
|
account: accountName(list),
|
||||||
|
shared: null,
|
||||||
|
assignmentMetadata: {
|
||||||
|
available: null,
|
||||||
|
note: "Apple Reminders automation does not expose shared-list participant metadata directly."
|
||||||
|
}
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const REMINDERS_LIST_SCRIPT = String.raw`
|
const REMINDERS_LIST_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Reminders");
|
const app = Application("/System/Applications/Reminders.app");
|
||||||
const lists = app.lists();
|
const lists = app.lists();
|
||||||
const found = [];
|
const found = [];
|
||||||
|
function stringValue(value) {
|
||||||
|
try {
|
||||||
|
return value === null || value === undefined ? null : String(value);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function compact(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
function propertyValue(object, names) {
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
try {
|
||||||
|
const getter = object[names[i]];
|
||||||
|
if (typeof getter !== "function") continue;
|
||||||
|
const value = getter.call(object);
|
||||||
|
const text = compact(stringValue(value));
|
||||||
|
if (text && !text.startsWith("[object ")) return text;
|
||||||
|
if (value && typeof value.name === "function") {
|
||||||
|
const name = compact(stringValue(value.name()));
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function assignmentHint(title, notes) {
|
||||||
|
const titleMatch = title.match(/\(([^()\n]{2,80})\)\s*$/);
|
||||||
|
if (titleMatch) return { assignee: titleMatch[1].trim(), source: "title" };
|
||||||
|
|
||||||
|
const explicit = notes.match(/\b(?:assigned to|assignee)\s*:\s*([^.;,\n]{2,80})/i);
|
||||||
|
if (explicit) return { assignee: explicit[1].trim(), source: "notes" };
|
||||||
|
|
||||||
|
const captured = notes.match(/\bCaptured\s+\d{4}-\d{2}-\d{2},\s*([^.;,\n]{2,80})\s*[.;]/i);
|
||||||
|
if (captured) return { assignee: captured[1].trim(), source: "notes" };
|
||||||
|
|
||||||
|
return { assignee: null, source: null };
|
||||||
|
}
|
||||||
|
function assignmentFor(reminder, title, notes) {
|
||||||
|
const nativeAssignee = propertyValue(reminder, [
|
||||||
|
"assignedTo",
|
||||||
|
"assignee",
|
||||||
|
"assignment",
|
||||||
|
"responsiblePerson",
|
||||||
|
"principal"
|
||||||
|
]);
|
||||||
|
if (nativeAssignee) {
|
||||||
|
return {
|
||||||
|
assignee: nativeAssignee,
|
||||||
|
source: "remindersAutomation",
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = assignmentHint(title, notes);
|
||||||
|
return {
|
||||||
|
assignee: hint.assignee,
|
||||||
|
source: hint.source,
|
||||||
|
available: hint.assignee !== null
|
||||||
|
};
|
||||||
|
}
|
||||||
for (let l = 0; l < lists.length && found.length < input.limit; l++) {
|
for (let l = 0; l < lists.length && found.length < input.limit; l++) {
|
||||||
const list = lists[l];
|
const list = lists[l];
|
||||||
const name = String(list.name());
|
const name = String(list.name());
|
||||||
@@ -28,13 +115,16 @@ function run(argv) {
|
|||||||
const value = reminder.dueDate();
|
const value = reminder.dueDate();
|
||||||
due = value ? value.toISOString() : null;
|
due = value ? value.toISOString() : null;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
const title = String(reminder.name());
|
||||||
|
const notes = String(reminder.body() || "");
|
||||||
found.push({
|
found.push({
|
||||||
id: String(reminder.id()),
|
id: String(reminder.id()),
|
||||||
list: name,
|
list: name,
|
||||||
title: String(reminder.name()),
|
title: title,
|
||||||
completed: completed,
|
completed: completed,
|
||||||
due: due,
|
due: due,
|
||||||
notes: String(reminder.body() || "")
|
notes: notes,
|
||||||
|
assignment: assignmentFor(reminder, title, notes)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +134,7 @@ function run(argv) {
|
|||||||
const REMINDER_CREATE_SCRIPT = String.raw`
|
const REMINDER_CREATE_SCRIPT = String.raw`
|
||||||
function run(argv) {
|
function run(argv) {
|
||||||
const input = JSON.parse(argv[0]);
|
const input = JSON.parse(argv[0]);
|
||||||
const app = Application("Reminders");
|
const app = Application("/System/Applications/Reminders.app");
|
||||||
const lists = app.lists();
|
const lists = app.lists();
|
||||||
let destination = null;
|
let destination = null;
|
||||||
for (let l = 0; l < lists.length; l++) {
|
for (let l = 0; l < lists.length; l++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user