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 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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+