chatwire-ha 1.0.0__tar.gz
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.
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chatwire-ha
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Home Assistant integration for chatwire — trigger HA automations/scenes via iMessage keywords.
|
|
5
|
+
Author: Allen Bina
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: chatwire,home-assistant,imessage,plugin
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# chatwire-ha\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# chatwire-ha\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""chatwire-ha — Home Assistant integration for chatwire.
|
|
2
|
+
|
|
3
|
+
Triggers HA automations/scenes/services via exact iMessage keyword matching.
|
|
4
|
+
Install with:
|
|
5
|
+
pip install chatwire-ha
|
|
6
|
+
|
|
7
|
+
Then add to config.json:
|
|
8
|
+
{
|
|
9
|
+
"integrations": {
|
|
10
|
+
"chatwire_ha": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"ha_url": "http://homeassistant.local:8123",
|
|
13
|
+
"access_token": "<long-lived token>",
|
|
14
|
+
"commands": [
|
|
15
|
+
{"keyword": "lights off", "domain": "light", "service": "turn_off",
|
|
16
|
+
"entity_id": "light.living_room", "description": "Living room lights off"},
|
|
17
|
+
{"keyword": "good night", "domain": "scene", "service": "turn_on",
|
|
18
|
+
"entity_id": "scene.night_mode", "description": "Night mode scene",
|
|
19
|
+
"allowed_senders": ["+15551234567", "alice@example.com"]}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Per-command ``allowed_senders``
|
|
26
|
+
-------------------------------
|
|
27
|
+
Each command entry accepts an optional ``allowed_senders`` list. When present
|
|
28
|
+
and non-empty, only messages from handles in that list trigger the command.
|
|
29
|
+
Matching is case-insensitive (email addresses) and exact (phone numbers).
|
|
30
|
+
An absent or empty ``allowed_senders`` list means *any* sender may trigger the
|
|
31
|
+
command (the pre-existing, backward-compatible behaviour).
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Lazy imports: integrations.base is only available when installed inside the
|
|
45
|
+
# chatwire tree. External packages reference it the same way; the bridge
|
|
46
|
+
# injects sys.path before loading entry-point plugins.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
try:
|
|
49
|
+
from integrations.base import BridgeContext, InboundMessage, SendTarget # type: ignore[import]
|
|
50
|
+
except ImportError: # pragma: no cover — only missing in isolated unit tests
|
|
51
|
+
BridgeContext = object # type: ignore[misc,assignment]
|
|
52
|
+
InboundMessage = object # type: ignore[misc,assignment]
|
|
53
|
+
SendTarget = None # type: ignore[misc,assignment]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HAIntegration:
|
|
57
|
+
"""Trigger Home Assistant services via iMessage keyword commands.
|
|
58
|
+
|
|
59
|
+
Each inbound message is checked against the configured keyword list
|
|
60
|
+
(stripped, lowercased, exact match). On a hit the integration POSTs to
|
|
61
|
+
the HA services API and replies to the sender with "Done: <description>".
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
NAME = "chatwire_ha"
|
|
65
|
+
TIER = "notify" # Third-party; receives SanitizedEvent only (no message text).
|
|
66
|
+
DISPLAY_NAME = "Home Assistant"
|
|
67
|
+
DESCRIPTION = "Trigger HA automations/scenes via iMessage keywords."
|
|
68
|
+
ICON = "🏠"
|
|
69
|
+
|
|
70
|
+
SETTINGS_SCHEMA: dict[str, Any] = {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"enabled": {
|
|
74
|
+
"type": "boolean",
|
|
75
|
+
"default": False,
|
|
76
|
+
"title": "Enable Home Assistant integration",
|
|
77
|
+
"x-ui-order": 0,
|
|
78
|
+
},
|
|
79
|
+
"ha_url": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"title": "Home Assistant URL",
|
|
82
|
+
"description": "Base URL of your HA instance, e.g. http://homeassistant.local:8123",
|
|
83
|
+
"x-ui-placeholder": "http://homeassistant.local:8123",
|
|
84
|
+
"x-ui-order": 1,
|
|
85
|
+
},
|
|
86
|
+
"access_token": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"title": "Long-lived access token",
|
|
89
|
+
"description": (
|
|
90
|
+
"Create one in HA under Profile → Long-Lived Access Tokens."
|
|
91
|
+
),
|
|
92
|
+
"x-ui-type": "password",
|
|
93
|
+
"x-ui-order": 2,
|
|
94
|
+
},
|
|
95
|
+
"commands": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"title": "Command mappings",
|
|
98
|
+
"description": (
|
|
99
|
+
"List of keyword → HA service mappings. Each keyword is "
|
|
100
|
+
"matched case-insensitively against the full message text."
|
|
101
|
+
),
|
|
102
|
+
"x-ui-order": 3,
|
|
103
|
+
"items": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"keyword": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"title": "Keyword",
|
|
109
|
+
"description": "Exact phrase the sender types (case-insensitive).",
|
|
110
|
+
},
|
|
111
|
+
"domain": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"title": "HA domain",
|
|
114
|
+
"description": "e.g. light, switch, scene, automation",
|
|
115
|
+
},
|
|
116
|
+
"service": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"title": "HA service",
|
|
119
|
+
"description": "e.g. turn_on, turn_off, trigger",
|
|
120
|
+
},
|
|
121
|
+
"entity_id": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"title": "Entity ID",
|
|
124
|
+
"description": "e.g. light.living_room, scene.night_mode",
|
|
125
|
+
},
|
|
126
|
+
"description": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"title": "Description",
|
|
129
|
+
"description": "Human-readable label sent back as the reply.",
|
|
130
|
+
},
|
|
131
|
+
"allowed_senders": {
|
|
132
|
+
"type": "array",
|
|
133
|
+
"title": "Allowed senders",
|
|
134
|
+
"description": (
|
|
135
|
+
"Optional list of handles (phone numbers or email addresses) "
|
|
136
|
+
"that may trigger this command. Empty = anyone can trigger it."
|
|
137
|
+
),
|
|
138
|
+
"items": {"type": "string"},
|
|
139
|
+
"default": [],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
"required": ["keyword", "domain", "service", "entity_id", "description"],
|
|
143
|
+
},
|
|
144
|
+
"default": [],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
"required": ["ha_url", "access_token"],
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
153
|
+
self._ha_url: str = (config.get("ha_url") or "").rstrip("/")
|
|
154
|
+
self._access_token: str = config.get("access_token") or ""
|
|
155
|
+
commands_raw: list[dict] = config.get("commands") or []
|
|
156
|
+
|
|
157
|
+
# Build lowercased-keyword → command mapping once at startup.
|
|
158
|
+
self._commands: dict[str, dict] = {}
|
|
159
|
+
for cmd in commands_raw:
|
|
160
|
+
kw = (cmd.get("keyword") or "").strip().lower()
|
|
161
|
+
if kw:
|
|
162
|
+
raw_senders: list = cmd.get("allowed_senders") or []
|
|
163
|
+
self._commands[kw] = {
|
|
164
|
+
"domain": cmd.get("domain", ""),
|
|
165
|
+
"service": cmd.get("service", ""),
|
|
166
|
+
"entity_id": cmd.get("entity_id", ""),
|
|
167
|
+
"description": cmd.get("description", ""),
|
|
168
|
+
# frozenset for O(1) lookup; lowercased for case-insensitive match.
|
|
169
|
+
"allowed_senders": frozenset(s.lower() for s in raw_senders if s),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
self._ctx: Any = None
|
|
173
|
+
self._client: httpx.AsyncClient | None = None
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Integration lifecycle
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def start(self, ctx: Any) -> None:
|
|
180
|
+
if not self._ha_url:
|
|
181
|
+
raise ValueError("chatwire_ha: 'ha_url' is required")
|
|
182
|
+
if not self._access_token:
|
|
183
|
+
raise ValueError("chatwire_ha: 'access_token' is required")
|
|
184
|
+
|
|
185
|
+
self._ctx = ctx
|
|
186
|
+
self._client = httpx.AsyncClient(
|
|
187
|
+
headers={"Authorization": f"Bearer {self._access_token}"},
|
|
188
|
+
timeout=10.0,
|
|
189
|
+
)
|
|
190
|
+
log.info(
|
|
191
|
+
"home_assistant integration started; HA at %s, %d command(s) registered",
|
|
192
|
+
self._ha_url,
|
|
193
|
+
len(self._commands),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def stop(self) -> None:
|
|
197
|
+
if self._client is not None:
|
|
198
|
+
await self._client.aclose()
|
|
199
|
+
self._client = None
|
|
200
|
+
log.info("home_assistant integration stopped")
|
|
201
|
+
|
|
202
|
+
async def on_inbound(self, msg: Any) -> None:
|
|
203
|
+
"""Check for a keyword match and fire the corresponding HA service."""
|
|
204
|
+
if self._client is None or self._ctx is None:
|
|
205
|
+
return # not started or already stopped — silently drop
|
|
206
|
+
|
|
207
|
+
text = (msg.text or "").strip().lower()
|
|
208
|
+
cmd = self._commands.get(text)
|
|
209
|
+
if cmd is None:
|
|
210
|
+
return # not a recognised keyword
|
|
211
|
+
|
|
212
|
+
# Allowed-sender filter: if the command has a non-empty allowed_senders
|
|
213
|
+
# set, only proceed when the message sender is in that set.
|
|
214
|
+
allowed = cmd["allowed_senders"]
|
|
215
|
+
if allowed and (msg.handle or "").lower() not in allowed:
|
|
216
|
+
log.debug(
|
|
217
|
+
"chatwire_ha: handle %r not in allowed_senders for keyword %r — skipped",
|
|
218
|
+
msg.handle,
|
|
219
|
+
text,
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
domain = cmd["domain"]
|
|
224
|
+
service = cmd["service"]
|
|
225
|
+
entity_id = cmd["entity_id"]
|
|
226
|
+
description = cmd["description"] or f"{domain}.{service}"
|
|
227
|
+
|
|
228
|
+
url = f"{self._ha_url}/api/services/{domain}/{service}"
|
|
229
|
+
try:
|
|
230
|
+
r = await self._client.post(url, json={"entity_id": entity_id})
|
|
231
|
+
if r.status_code >= 400:
|
|
232
|
+
log.warning(
|
|
233
|
+
"HA service call %s -> HTTP %d: %s",
|
|
234
|
+
url, r.status_code, r.text[:200],
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
except (httpx.HTTPError, asyncio.TimeoutError) as exc:
|
|
238
|
+
log.warning(
|
|
239
|
+
"HA service call failed: %s: %s", type(exc).__name__, exc
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Reply to the sender (1:1 or group).
|
|
244
|
+
if SendTarget is None:
|
|
245
|
+
return # pragma: no cover
|
|
246
|
+
|
|
247
|
+
if msg.is_group:
|
|
248
|
+
target = SendTarget(
|
|
249
|
+
kind="chat",
|
|
250
|
+
value=msg.chat_guid,
|
|
251
|
+
label=msg.chat_name or msg.chat_identifier,
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
label = self._ctx.name_for(msg.handle) or msg.handle
|
|
255
|
+
target = SendTarget(
|
|
256
|
+
kind="handle",
|
|
257
|
+
value=msg.handle,
|
|
258
|
+
label=label,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
await self._ctx.send_text(target, f"Done: {description}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chatwire-ha"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Home Assistant integration for chatwire — trigger HA automations/scenes via iMessage keywords."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Allen Bina" }]
|
|
13
|
+
keywords = ["chatwire", "home-assistant", "imessage", "plugin"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.entry-points."chatwire.integrations"]
|
|
27
|
+
chatwire_ha = "chatwire_ha:HAIntegration"
|