openaction 0.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api/__init__.py +0 -0
- api/http_service.py +61 -0
- api/mcp_service.py +68 -0
- api/store_service.py +59 -0
- cron.py +153 -0
- http_client.py +63 -0
- mcp_client.py +114 -0
- openaction-0.0.13.dist-info/METADATA +42 -0
- openaction-0.0.13.dist-info/RECORD +17 -0
- openaction-0.0.13.dist-info/WHEEL +5 -0
- openaction-0.0.13.dist-info/licenses/LICENSE +201 -0
- openaction-0.0.13.dist-info/top_level.txt +9 -0
- openaction.py +620 -0
- services.py +205 -0
- store.py +206 -0
- task.py +198 -0
- task_repository.py +293 -0
api/__init__.py
ADDED
|
File without changes
|
api/http_service.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
from requests import Response
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpClient(ABC):
|
|
7
|
+
"""
|
|
8
|
+
The HTTP client supports local serives like Shelly devices and external
|
|
9
|
+
services. Please ensure compliance with terms of use and privacy policies
|
|
10
|
+
for external requests.
|
|
11
|
+
|
|
12
|
+
This wrapper encapsulates communication with remote servers while managing
|
|
13
|
+
the connection lifecycle internally (e.g., session handling and pooling).
|
|
14
|
+
|
|
15
|
+
Note: Client instances are typically dedicated to specific tasks and are
|
|
16
|
+
not intended to be shared across concurrent executions to ensure isolation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
|
21
|
+
"""
|
|
22
|
+
Sends a generic HTTP request.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
method (str): The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
26
|
+
url (str): The target destination URL.
|
|
27
|
+
**kwargs (Any): Arguments passed to the underlying engine, such as
|
|
28
|
+
'headers', 'params', 'json', or 'timeout'.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Response: A `requests.Response` object containing the server's reply.
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def get(self, url: str, **kwargs: Any) -> Response:
|
|
37
|
+
"""
|
|
38
|
+
Sends an HTTP GET request. A convenience wrapper for request("GET", ...).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
url (str): The target destination URL.
|
|
42
|
+
**kwargs (Any): Optional arguments like 'params' or 'headers'.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Response: The server's response.
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def post(self, url: str, **kwargs: Any) -> Response:
|
|
51
|
+
"""
|
|
52
|
+
Sends an HTTP POST request. A convenience wrapper for request("POST", ...).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
url (str): The target destination URL.
|
|
56
|
+
**kwargs (Any): Optional arguments like 'json', 'data', or 'headers'.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Response: The server's response.
|
|
60
|
+
"""
|
|
61
|
+
pass
|
api/mcp_service.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from mcp import ListToolsResult
|
|
5
|
+
from mcp.types import CallToolResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPClient(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract base class providing a standardized interface for Model Context Protocol (MCP) clients.
|
|
11
|
+
|
|
12
|
+
This wrapper encapsulates communication with connected MCP servers. Connection
|
|
13
|
+
lifecycle management is handled internally.
|
|
14
|
+
|
|
15
|
+
Note: Client instances are typically dedicated to specific tasks and are not
|
|
16
|
+
intended to be shared across concurrent executions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def list_tools(self) -> ListToolsResult:
|
|
21
|
+
"""
|
|
22
|
+
Retrieves the manifest of available tools from the connected MCP server.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
ListToolsResult: An object containing the tool definitions and their
|
|
26
|
+
respective JSON Schema specifications.
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult:
|
|
32
|
+
"""
|
|
33
|
+
Invokes a functional tool on the remote MCP server.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name (str): The unique identifier of the tool to execute.
|
|
37
|
+
arguments (dict[str, Any] | None, optional): Parameters for the tool,
|
|
38
|
+
matching its defined JSON Schema. Defaults to None.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
CallToolResult: The structured response from the server, including
|
|
42
|
+
content (text/images) and success status.
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MCPClientRegistry(ABC):
|
|
49
|
+
"""
|
|
50
|
+
Registry service for discovery and orchestration of multiple MCP clients.
|
|
51
|
+
|
|
52
|
+
Acts as a central access point for retrieving specific client instances
|
|
53
|
+
by their configured identifiers.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def get(self, name: str) -> Optional[MCPClient]:
|
|
58
|
+
"""
|
|
59
|
+
Resolves and returns an MCPClient instance by its registration name.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name (str): The unique identifier of the target MCP client.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Optional[MCPClient]: The requested client instance, or None if
|
|
66
|
+
no client is registered under that name.
|
|
67
|
+
"""
|
|
68
|
+
pass
|
api/store_service.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
class StoreService(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Key-value storage service.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def put(self, key: str, value: str, ttl_sec: int | None = None) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Store a value in the storage with the specified key. The amount of stored data
|
|
13
|
+
should not exceed 4 KB. All entries must be deleted. No data should be left behind.
|
|
14
|
+
The data is stored individually for each task.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
key (str): The unique identifier for the stored value.
|
|
18
|
+
value (Any): The data to be stored.
|
|
19
|
+
ttl_sec (int | None, optional): Time-To-Live in seconds. If provided,
|
|
20
|
+
the key-value pair will expire and be
|
|
21
|
+
removed after this duration. Defaults to None.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get(self, key: str, default_value: str = None) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Retrieve a value from the storage using its key.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
key (str): The unique identifier of the value to retrieve.
|
|
32
|
+
default_value (Any, optional): The value to return if the key is not found
|
|
33
|
+
in the storage. Defaults to None.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Any: The stored value if the key exists, otherwise the default_value.
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def delete(self, key: str) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Remove a key-value pair from the storage.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
key (str): The unique identifier of the value to be deleted.
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def keys(self) -> list[str]:
|
|
53
|
+
"""
|
|
54
|
+
Retrieve a list of all keys currently stored.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
list[str]: A list of keys in the storage.
|
|
58
|
+
"""
|
|
59
|
+
pass
|
cron.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from concurrent.futures.thread import ThreadPoolExecutor
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from time import sleep
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
from task import CronTaskAdapter, TaskFactory
|
|
9
|
+
from task_repository import TaskRepository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CronService:
|
|
16
|
+
|
|
17
|
+
def __init__(self, task_repository : TaskRepository):
|
|
18
|
+
self._is_running = False
|
|
19
|
+
self._task_repository = task_repository
|
|
20
|
+
self._cron_cache: Dict[str, set[int]] = {}
|
|
21
|
+
self._executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="CronWorker")
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return f"CronService(jobs={len(self._task_repository.tasks)})\n\r" + "\n\r".join([" * " + str(task) for task in self._task_repository.tasks])
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
self._is_running = False
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def start(self):
|
|
31
|
+
self._is_running = True
|
|
32
|
+
Thread(target=self.__loop, daemon=True).start()
|
|
33
|
+
|
|
34
|
+
def __loop(self):
|
|
35
|
+
while self._is_running:
|
|
36
|
+
now = datetime.now()
|
|
37
|
+
run_key = (now.year, now.month, now.day, now.hour, now.minute)
|
|
38
|
+
|
|
39
|
+
for task in list(self._task_repository.tasks.values()):
|
|
40
|
+
try:
|
|
41
|
+
if self._should_run(task, now, run_key):
|
|
42
|
+
self._executor.submit(self._safe_run, task)
|
|
43
|
+
except Exception:
|
|
44
|
+
logger.exception(f"Error triggering task: {task.name}")
|
|
45
|
+
|
|
46
|
+
next_tick = (datetime.now() + timedelta(seconds=1)).replace(microsecond=0)
|
|
47
|
+
sleep_time = (next_tick - datetime.now()).total_seconds()
|
|
48
|
+
sleep(sleep_time)
|
|
49
|
+
|
|
50
|
+
def _safe_run(self, task: CronTaskAdapter):
|
|
51
|
+
"""Executes the task and ensures any unhandled exceptions are logged."""
|
|
52
|
+
try:
|
|
53
|
+
task.run()
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f"Execution failed for task '{task.name}': {e}")
|
|
56
|
+
|
|
57
|
+
def _should_run(self, task: CronTaskAdapter, now: datetime, run_key: tuple[int, int, int, int, int]) -> bool:
|
|
58
|
+
# If task failed recently, wait 1 minute before retrying
|
|
59
|
+
time_since_error = task.last_failure_age()
|
|
60
|
+
if time_since_error is not None:
|
|
61
|
+
if time_since_error < timedelta(minutes=1):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Check if already run in this minute or if cron expression matches
|
|
65
|
+
last_run = task.last_attempt_at()
|
|
66
|
+
if last_run is not None:
|
|
67
|
+
if (last_run.year, last_run.month, last_run.day, last_run.hour, last_run.minute) == run_key:
|
|
68
|
+
return False
|
|
69
|
+
return self._matches(task.cron_expression, now)
|
|
70
|
+
|
|
71
|
+
def _validate_cron_expression(self, expression: str) -> None:
|
|
72
|
+
fields = expression.split()
|
|
73
|
+
if len(fields) != 5:
|
|
74
|
+
raise ValueError(f"Invalid cron expression '{expression}'. Expected 5 fields.")
|
|
75
|
+
|
|
76
|
+
ranges = [
|
|
77
|
+
(0, 59),
|
|
78
|
+
(0, 23),
|
|
79
|
+
(1, 31),
|
|
80
|
+
(1, 12),
|
|
81
|
+
(0, 7),
|
|
82
|
+
]
|
|
83
|
+
for field, (minimum, maximum) in zip(fields, ranges, strict=True):
|
|
84
|
+
self._parse_field(field, minimum, maximum)
|
|
85
|
+
|
|
86
|
+
def _matches(self, expression: str, now: datetime) -> bool:
|
|
87
|
+
"""Splits the cron expression and evaluates each field."""
|
|
88
|
+
try:
|
|
89
|
+
minute, hour, day, month, weekday = expression.split()
|
|
90
|
+
cron_weekday = (now.weekday() + 1) % 7 # ISO (Mon=0) to Cron (Sun=0/7)
|
|
91
|
+
|
|
92
|
+
# Pass unique cache keys for each field type
|
|
93
|
+
return (
|
|
94
|
+
self._matches_field(minute, now.minute, 0, 59, "m")
|
|
95
|
+
and self._matches_field(hour, now.hour, 0, 23, "h")
|
|
96
|
+
and self._matches_field(day, now.day, 1, 31, "d")
|
|
97
|
+
and self._matches_field(month, now.month, 1, 12, "M")
|
|
98
|
+
and self._matches_field(weekday, cron_weekday, 0, 7, "w")
|
|
99
|
+
)
|
|
100
|
+
except ValueError:
|
|
101
|
+
logger.error(f"Invalid cron expression encountered: {expression}")
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def _matches_field(self, field: str, value: int, minimum: int, maximum: int, cache_key_part: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Checks a specific time field (e.g., 'minute') against its cron part.
|
|
107
|
+
Uses caching to avoid re-parsing identical expressions across tasks.
|
|
108
|
+
"""
|
|
109
|
+
cache_key = f"{cache_key_part}:{field}:{minimum}:{maximum}"
|
|
110
|
+
if cache_key not in self._cron_cache:
|
|
111
|
+
self._cron_cache[cache_key] = self._parse_field(field, minimum, maximum)
|
|
112
|
+
|
|
113
|
+
allowed_values = self._cron_cache[cache_key]
|
|
114
|
+
|
|
115
|
+
# Special case for Sunday: support both 0 and 7.
|
|
116
|
+
if maximum == 7 and value == 0 and 7 in allowed_values:
|
|
117
|
+
return True
|
|
118
|
+
return value in allowed_values
|
|
119
|
+
|
|
120
|
+
def _parse_field(self, field: str, minimum: int, maximum: int) -> set[int]:
|
|
121
|
+
values: set[int] = set()
|
|
122
|
+
|
|
123
|
+
for part in field.split(","):
|
|
124
|
+
part = part.strip()
|
|
125
|
+
if not part:
|
|
126
|
+
raise ValueError("Empty cron field part")
|
|
127
|
+
|
|
128
|
+
if "/" in part:
|
|
129
|
+
base, step_text = part.split("/", 1)
|
|
130
|
+
step = int(step_text)
|
|
131
|
+
if step <= 0:
|
|
132
|
+
raise ValueError(f"Invalid cron step '{part}'")
|
|
133
|
+
else:
|
|
134
|
+
base = part
|
|
135
|
+
step = 1
|
|
136
|
+
|
|
137
|
+
if base == "*":
|
|
138
|
+
start = minimum
|
|
139
|
+
end = maximum
|
|
140
|
+
elif "-" in base:
|
|
141
|
+
start_text, end_text = base.split("-", 1)
|
|
142
|
+
start = int(start_text)
|
|
143
|
+
end = int(end_text)
|
|
144
|
+
else:
|
|
145
|
+
start = int(base)
|
|
146
|
+
end = int(base)
|
|
147
|
+
|
|
148
|
+
if start < minimum or end > maximum or start > end:
|
|
149
|
+
raise ValueError(f"Cron value '{part}' out of range {minimum}-{maximum}")
|
|
150
|
+
|
|
151
|
+
values.update(range(start, end + 1, step))
|
|
152
|
+
|
|
153
|
+
return values
|
http_client.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import requests
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
from requests import Response, Session
|
|
6
|
+
|
|
7
|
+
from api.http_service import HttpClient
|
|
8
|
+
|
|
9
|
+
class AutoRecreateHttpClient(HttpClient):
|
|
10
|
+
def __init__(self, ttl_minutes: int = 30) -> None:
|
|
11
|
+
self.ttl: timedelta = timedelta(minutes=ttl_minutes)
|
|
12
|
+
self.last_created: datetime | None = None
|
|
13
|
+
self.session: Session = self._create_session()
|
|
14
|
+
|
|
15
|
+
def _create_session(self) -> Session:
|
|
16
|
+
"""Closes the old session (if any) and opens a new one."""
|
|
17
|
+
if hasattr(self, 'session') and self.session:
|
|
18
|
+
try:
|
|
19
|
+
self.session.close()
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
session = requests.Session()
|
|
24
|
+
self.last_created = datetime.now()
|
|
25
|
+
logging.info("New http session created")
|
|
26
|
+
return session
|
|
27
|
+
|
|
28
|
+
def _is_expired(self) -> bool:
|
|
29
|
+
assert self.last_created is not None
|
|
30
|
+
return datetime.now() - self.last_created > self.ttl
|
|
31
|
+
|
|
32
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
|
33
|
+
# 1. Pre-check: Has the time expired?
|
|
34
|
+
if self._is_expired():
|
|
35
|
+
logging.info("TTL reached. Renewing session before request.")
|
|
36
|
+
self.session = self._create_session()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Execute request
|
|
40
|
+
response = self.session.request(method, url, **kwargs)
|
|
41
|
+
|
|
42
|
+
# Optional: On 401, also recreate the session for the next call
|
|
43
|
+
if response.status_code == 401:
|
|
44
|
+
logging.warning("Status 401: Session will be renewed for the next call.")
|
|
45
|
+
self.session = self._create_session()
|
|
46
|
+
|
|
47
|
+
return response
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
# 2. Error-check: Recreate immediately on exception
|
|
51
|
+
logging.exception("Exception caught: %s", e)
|
|
52
|
+
logging.info("Session will be re-initialized for the next attempt.")
|
|
53
|
+
self.session = self._create_session()
|
|
54
|
+
|
|
55
|
+
# Pass error directly (no retry)
|
|
56
|
+
raise e
|
|
57
|
+
|
|
58
|
+
# Shortcuts
|
|
59
|
+
def get(self, url: str, **kwargs: Any) -> Response:
|
|
60
|
+
return self.request('GET', url, **kwargs)
|
|
61
|
+
|
|
62
|
+
def post(self, url: str, **kwargs: Any) -> Response:
|
|
63
|
+
return self.request('POST', url, **kwargs)
|
mcp_client.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from contextlib import AsyncExitStack
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
from mcp import ClientSession
|
|
7
|
+
from mcp.client.sse import sse_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from api.mcp_service import MCPClientRegistry, MCPClient
|
|
11
|
+
from services import MCP_SSE, ServiceRegistry
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncMCPClient(MCPClient):
|
|
17
|
+
"""Synchronous MCP client communicating over HTTP/SSE transport."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, url: str):
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
url: The HTTP SSE endpoint of the MCP server, e.g. 'http://host:port/sse'.
|
|
23
|
+
"""
|
|
24
|
+
self.url = url
|
|
25
|
+
self._session = None
|
|
26
|
+
self._exit_stack = None
|
|
27
|
+
self._loop = asyncio.new_event_loop()
|
|
28
|
+
self._thread = threading.Thread(target=self._start_loop, daemon=True)
|
|
29
|
+
self._thread.start()
|
|
30
|
+
|
|
31
|
+
def _start_loop(self):
|
|
32
|
+
asyncio.set_event_loop(self._loop)
|
|
33
|
+
self._loop.run_forever()
|
|
34
|
+
|
|
35
|
+
def _run_sync(self, coroutine):
|
|
36
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
37
|
+
return future.result()
|
|
38
|
+
|
|
39
|
+
async def _connect_async(self):
|
|
40
|
+
self._exit_stack = AsyncExitStack()
|
|
41
|
+
|
|
42
|
+
# Open HTTP/SSE transport to the MCP server
|
|
43
|
+
sse_transport = await self._exit_stack.enter_async_context(sse_client(self.url))
|
|
44
|
+
read, write = sse_transport
|
|
45
|
+
|
|
46
|
+
# Wrap transport in a ClientSession and perform MCP handshake
|
|
47
|
+
self._session = await self._exit_stack.enter_async_context(ClientSession(read, write))
|
|
48
|
+
await self._session.initialize()
|
|
49
|
+
|
|
50
|
+
def __connect(self):
|
|
51
|
+
self._run_sync(self._connect_async())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __close(self):
|
|
55
|
+
if self._exit_stack:
|
|
56
|
+
self._run_sync(self._exit_stack.aclose())
|
|
57
|
+
self._loop.call_soon_threadsafe(lambda: self._loop.stop())
|
|
58
|
+
self._thread.join()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ==========================================
|
|
62
|
+
# Public synchronous API
|
|
63
|
+
# ==========================================
|
|
64
|
+
|
|
65
|
+
def list_tools(self):
|
|
66
|
+
if not self._session:
|
|
67
|
+
self.__connect()
|
|
68
|
+
try:
|
|
69
|
+
return self._run_sync(self._session.list_tools())
|
|
70
|
+
except Exception as e:
|
|
71
|
+
self.__close()
|
|
72
|
+
raise e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def call_tool(self, name: str, arguments: dict = None):
|
|
76
|
+
if not self._session:
|
|
77
|
+
self.__connect()
|
|
78
|
+
try:
|
|
79
|
+
return self._run_sync(self._session.call_tool(name, arguments or {}))
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.__close()
|
|
82
|
+
raise e
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return f"SyncMCPClient(url='{self.url}')"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class McpRegistry(MCPClientRegistry):
|
|
89
|
+
|
|
90
|
+
def __init__(self, service_registry: ServiceRegistry):
|
|
91
|
+
self._mcp: Dict[str, SyncMCPClient] = {}
|
|
92
|
+
self._service_registry = service_registry
|
|
93
|
+
self._service_registry.add_listener(self._refresh)
|
|
94
|
+
self._refresh()
|
|
95
|
+
|
|
96
|
+
def __del__(self):
|
|
97
|
+
self._service_registry.remove_listener(self._refresh)
|
|
98
|
+
|
|
99
|
+
def get(self, name: str) -> Optional[MCPClient]:
|
|
100
|
+
"""Return the MCP client for the given name, or None."""
|
|
101
|
+
return self._mcp.get(name)
|
|
102
|
+
|
|
103
|
+
def clone(self):
|
|
104
|
+
return McpRegistry(self._service_registry)
|
|
105
|
+
|
|
106
|
+
def _refresh(self):
|
|
107
|
+
for name, conf in dict(self._service_registry.registered_services).items():
|
|
108
|
+
if conf.type == MCP_SSE:
|
|
109
|
+
if name not in self._mcp.keys():
|
|
110
|
+
try:
|
|
111
|
+
self._mcp[name] = SyncMCPClient(conf.url)
|
|
112
|
+
logger.debug(f"{'auto scanned' if conf.auto_scanned else 'manually'} configured MCP server '{name}' added")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning(f"Error adding MCP server '{name}': {e}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openaction
|
|
3
|
+
Version: 0.0.13
|
|
4
|
+
Summary: AI-driven framework for smart home automation
|
|
5
|
+
Author: OpenAction Contributors
|
|
6
|
+
License: Apache License 2.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Requires-Dist: appdirs>=1.4.4
|
|
15
|
+
Requires-Dist: mcp>=1.27.0
|
|
16
|
+
Requires-Dist: zeroconf>=0.148.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# OpenAction
|
|
20
|
+
|
|
21
|
+
**OpenAction** is an AI-driven framework for smart home automation. It replaces static, manually created rules with dynamic scripts generated and managed by an Artificial Intelligence (LLM) based on natural language.
|
|
22
|
+
|
|
23
|
+
## Core Features
|
|
24
|
+
|
|
25
|
+
* **Natural Language Automation:** Create complex smart home automations through simple chat dialogues with an LLM (such as Claude).
|
|
26
|
+
* **Sensors & Actuators:** All hardware devices provide their interfaces as independent MCP services.
|
|
27
|
+
* **OpenAction Interface:** The framework itself acts as an MCP server. The AI uses provided tools to create and manage automations within the system.
|
|
28
|
+
* **Dynamic Scheduling:** An integrated Cron service ensures the precise execution of AI-generated scripts.
|
|
29
|
+
* **Persistent State:** Tasks have access to a persistent `Store` to save states across multiple executions.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Architecture & Workflow
|
|
34
|
+
|
|
35
|
+
The system is based on a decentralized architecture connected via the Model Context Protocol (MCP):
|
|
36
|
+
|
|
37
|
+
1. **The Request:** The user describes an automation in a chat with the AI (e.g., Claude Desktop).
|
|
38
|
+
2. **The Translation:** The AI analyzes the prompt, writes a Python script (including a cron rule and an execute function), and uses tools of the **OpenAction MCP Server** to store the script in the system.
|
|
39
|
+
3. **The Scheduling:** OpenAction's local services monitor the schedules and trigger the tasks at the specified times.
|
|
40
|
+
4. **The Execution:** During execution, the script calls the corresponding endpoints of the connected hardware MCP servers (e.g., switching lights, reading temperatures) via the `McpRegistry`.
|
|
41
|
+
|
|
42
|
+
---
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
cron.py,sha256=YfRnYm1e-mz0r0GJD4ZD0JefFQz7-a5k2M6tBXgf3bs,5861
|
|
2
|
+
http_client.py,sha256=9tRQh7VnBxPrHcSw8v9Z_AqbYZxdxjrhPwiLijVk0CI,2264
|
|
3
|
+
mcp_client.py,sha256=2xCFVYFOQ7Kew6-U_etHNGs4EC7wJHvBtnIuDoNcWuk,3699
|
|
4
|
+
openaction.py,sha256=5f9tLrXsX4Vu8uxtJh-6uhWWwxTGLRIGuLSyAXSG-Gs,26833
|
|
5
|
+
services.py,sha256=mkYHBJr40khJ8PinNfQ1Cu2CysyQKD5JBf-4pMxrGSQ,7530
|
|
6
|
+
store.py,sha256=Du-pO8N0oJ-7sVk8AYoA3YRPKfdQQgm9vkGwKc-0v9g,7306
|
|
7
|
+
task.py,sha256=6A5ItLjl7zL6OTb7QDhw16vmQ-1H6Z7zDhyzzwj0jG4,7369
|
|
8
|
+
task_repository.py,sha256=krBNz2ofmYOvAU6UJNKFyO9-pd1FsNCCK2wyVUI8zmI,11098
|
|
9
|
+
api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
api/http_service.py,sha256=Zkjl5l6Wj31sxUkZdF5njA6LFdPaHTxZuxtHlF1YNBU,1991
|
|
11
|
+
api/mcp_service.py,sha256=2qtGtCUuSDbAZ3lWvqeBBgDBvd6somYOAV0LR1k7Eo8,2198
|
|
12
|
+
api/store_service.py,sha256=Q3eaPEVQj1gRjctv2FgLDxeMD_w5j8g4lYn15KYxrcc,1867
|
|
13
|
+
openaction-0.0.13.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
14
|
+
openaction-0.0.13.dist-info/METADATA,sha256=ci9wJdQ7o4GL7hKeWGCn4o35gBZL5-ZTBSqATFXYHV4,2170
|
|
15
|
+
openaction-0.0.13.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
openaction-0.0.13.dist-info/top_level.txt,sha256=xabz_jrdTyRWtuia-vBgzW2IAKuu9c0akhf8qIa_hnk,79
|
|
17
|
+
openaction-0.0.13.dist-info/RECORD,,
|