ovos-skill-intercom 0.0.1__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.
- ovos_skill_intercom/__init__.py +443 -0
- ovos_skill_intercom/build_icon.py +36 -0
- ovos_skill_intercom/locale/da-dk/code_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/code_set.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/message_from.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/message_sent.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/name_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/name_set.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/own_code_not_set.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/own_name_not_set.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/reply_failed.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/reply_sent.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/say_your_reply.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/send_message.intent +3 -0
- ovos_skill_intercom/locale/da-dk/set_intercom_code.intent +1 -0
- ovos_skill_intercom/locale/da-dk/set_intercom_name.intent +2 -0
- ovos_skill_intercom/locale/da-dk/skill.json +22 -0
- ovos_skill_intercom/locale/da-dk/target_code_mismatch.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/target_no_code_set.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/target_not_found.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/target_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/target_unreachable.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/want_to_reply.dialog +1 -0
- ovos_skill_intercom/locale/da-dk/what_to_say.dialog +1 -0
- ovos_skill_intercom/locale/de-de/code_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/de-de/code_set.dialog +1 -0
- ovos_skill_intercom/locale/de-de/message_from.dialog +1 -0
- ovos_skill_intercom/locale/de-de/message_sent.dialog +1 -0
- ovos_skill_intercom/locale/de-de/name_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/de-de/name_set.dialog +1 -0
- ovos_skill_intercom/locale/de-de/own_code_not_set.dialog +1 -0
- ovos_skill_intercom/locale/de-de/own_name_not_set.dialog +1 -0
- ovos_skill_intercom/locale/de-de/reply_failed.dialog +1 -0
- ovos_skill_intercom/locale/de-de/reply_sent.dialog +1 -0
- ovos_skill_intercom/locale/de-de/say_your_reply.dialog +1 -0
- ovos_skill_intercom/locale/de-de/send_message.intent +3 -0
- ovos_skill_intercom/locale/de-de/set_intercom_code.intent +1 -0
- ovos_skill_intercom/locale/de-de/set_intercom_name.intent +2 -0
- ovos_skill_intercom/locale/de-de/skill.json +22 -0
- ovos_skill_intercom/locale/de-de/target_code_mismatch.dialog +1 -0
- ovos_skill_intercom/locale/de-de/target_no_code_set.dialog +1 -0
- ovos_skill_intercom/locale/de-de/target_not_found.dialog +1 -0
- ovos_skill_intercom/locale/de-de/target_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/de-de/target_unreachable.dialog +1 -0
- ovos_skill_intercom/locale/de-de/want_to_reply.dialog +1 -0
- ovos_skill_intercom/locale/de-de/what_to_say.dialog +1 -0
- ovos_skill_intercom/locale/en-us/code_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/en-us/code_set.dialog +1 -0
- ovos_skill_intercom/locale/en-us/message_from.dialog +1 -0
- ovos_skill_intercom/locale/en-us/message_sent.dialog +1 -0
- ovos_skill_intercom/locale/en-us/name_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/en-us/name_set.dialog +1 -0
- ovos_skill_intercom/locale/en-us/own_code_not_set.dialog +1 -0
- ovos_skill_intercom/locale/en-us/own_name_not_set.dialog +1 -0
- ovos_skill_intercom/locale/en-us/reply_failed.dialog +1 -0
- ovos_skill_intercom/locale/en-us/reply_sent.dialog +1 -0
- ovos_skill_intercom/locale/en-us/say_your_reply.dialog +1 -0
- ovos_skill_intercom/locale/en-us/send_message.intent +3 -0
- ovos_skill_intercom/locale/en-us/set_intercom_code.intent +1 -0
- ovos_skill_intercom/locale/en-us/set_intercom_name.intent +2 -0
- ovos_skill_intercom/locale/en-us/skill.json +22 -0
- ovos_skill_intercom/locale/en-us/target_code_mismatch.dialog +1 -0
- ovos_skill_intercom/locale/en-us/target_no_code_set.dialog +1 -0
- ovos_skill_intercom/locale/en-us/target_not_found.dialog +1 -0
- ovos_skill_intercom/locale/en-us/target_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/en-us/target_unreachable.dialog +1 -0
- ovos_skill_intercom/locale/en-us/want_to_reply.dialog +1 -0
- ovos_skill_intercom/locale/en-us/what_to_say.dialog +1 -0
- ovos_skill_intercom/locale/es-es/code_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/es-es/code_set.dialog +1 -0
- ovos_skill_intercom/locale/es-es/message_from.dialog +1 -0
- ovos_skill_intercom/locale/es-es/message_sent.dialog +1 -0
- ovos_skill_intercom/locale/es-es/name_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/es-es/name_set.dialog +1 -0
- ovos_skill_intercom/locale/es-es/own_code_not_set.dialog +1 -0
- ovos_skill_intercom/locale/es-es/own_name_not_set.dialog +1 -0
- ovos_skill_intercom/locale/es-es/reply_failed.dialog +1 -0
- ovos_skill_intercom/locale/es-es/reply_sent.dialog +1 -0
- ovos_skill_intercom/locale/es-es/say_your_reply.dialog +1 -0
- ovos_skill_intercom/locale/es-es/send_message.intent +2 -0
- ovos_skill_intercom/locale/es-es/set_intercom_code.intent +1 -0
- ovos_skill_intercom/locale/es-es/set_intercom_name.intent +2 -0
- ovos_skill_intercom/locale/es-es/skill.json +22 -0
- ovos_skill_intercom/locale/es-es/target_code_mismatch.dialog +1 -0
- ovos_skill_intercom/locale/es-es/target_no_code_set.dialog +1 -0
- ovos_skill_intercom/locale/es-es/target_not_found.dialog +1 -0
- ovos_skill_intercom/locale/es-es/target_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/es-es/target_unreachable.dialog +1 -0
- ovos_skill_intercom/locale/es-es/want_to_reply.dialog +1 -0
- ovos_skill_intercom/locale/es-es/what_to_say.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/code_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/code_set.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/message_from.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/message_sent.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/name_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/name_set.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/own_code_not_set.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/own_name_not_set.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/reply_failed.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/reply_sent.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/say_your_reply.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/send_message.intent +2 -0
- ovos_skill_intercom/locale/fr-fr/set_intercom_code.intent +1 -0
- ovos_skill_intercom/locale/fr-fr/set_intercom_name.intent +2 -0
- ovos_skill_intercom/locale/fr-fr/skill.json +22 -0
- ovos_skill_intercom/locale/fr-fr/target_code_mismatch.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/target_no_code_set.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/target_not_found.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/target_not_understood.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/target_unreachable.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/want_to_reply.dialog +1 -0
- ovos_skill_intercom/locale/fr-fr/what_to_say.dialog +1 -0
- ovos_skill_intercom/version.py +5 -0
- ovos_skill_intercom-0.0.1.dist-info/METADATA +146 -0
- ovos_skill_intercom-0.0.1.dist-info/RECORD +119 -0
- ovos_skill_intercom-0.0.1.dist-info/WHEEL +5 -0
- ovos_skill_intercom-0.0.1.dist-info/entry_points.txt +2 -0
- ovos_skill_intercom-0.0.1.dist-info/licenses/LICENSE +674 -0
- ovos_skill_intercom-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skill OVOS Intercom
|
|
3
|
+
Copyright (C) 2026 Andreas Lorensen
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU General Public License as published by
|
|
7
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU General Public License
|
|
16
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
LAN intercom for OVOS - "send a message to the bedroom" delivers a
|
|
21
|
+
spoken message to a named peer OVOS device on the same local network,
|
|
22
|
+
with an optional spoken reply sent back. See README.md and
|
|
23
|
+
DEVELOPMENT.md for the full design reasoning; the single sentence
|
|
24
|
+
that matters most: THIS SKILL SPEAKS MESSAGES, IT NEVER EXECUTES
|
|
25
|
+
COMMANDS. A received message is only ever passed to self.speak() -
|
|
26
|
+
never routed through the intent pipeline. That boundary is the entire
|
|
27
|
+
reason this can be a small, self-contained skill instead of a much
|
|
28
|
+
bigger, much more dangerous piece of infrastructure - see
|
|
29
|
+
DEVELOPMENT.md "Why not just relay to the target's messagebus" and
|
|
30
|
+
"Why this isn't a smaller HiveMind".
|
|
31
|
+
|
|
32
|
+
Household-wide shared secret, not pairwise pairing: every device that
|
|
33
|
+
should be able to send/receive intercom messages has the SAME code
|
|
34
|
+
spoken to it once (`"set intercom code to ..."`), rather than each
|
|
35
|
+
PAIR of devices needing its own separate secret - scales linearly
|
|
36
|
+
(N devices = N spoken setup commands) instead of quadratically
|
|
37
|
+
(N devices = N*(N-1)/2 pairwise setups). Proportionate to "keep
|
|
38
|
+
casual LAN presence out", the same security level as a household
|
|
39
|
+
WiFi password, not "defend against a targeted attacker" - seeDEVELOPMENT.md
|
|
40
|
+
"Threat model: a WiFi-password-equivalent, not HiveMind-grade".
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import socket
|
|
45
|
+
import threading
|
|
46
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
47
|
+
|
|
48
|
+
from ovos_workshop.skills import OVOSSkill
|
|
49
|
+
from ovos_workshop.decorators import intent_handler
|
|
50
|
+
from ovos_bus_client.message import Message
|
|
51
|
+
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceListener
|
|
52
|
+
|
|
53
|
+
SERVICE_TYPE = "_ovosintercom._tcp.local."
|
|
54
|
+
INCOMING_MESSAGE_BUS_EVENT = "ovos.intercom.message.received"
|
|
55
|
+
|
|
56
|
+
# Response statuses the receiver's HTTP endpoint can return - see
|
|
57
|
+
# DEVELOPMENT.md "Wire protocol" for the full request/response shape.
|
|
58
|
+
STATUS_OK = "ok"
|
|
59
|
+
STATUS_NO_CODE_SET = "no_code_set"
|
|
60
|
+
STATUS_WRONG_CODE = "wrong_code"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_code(raw):
|
|
64
|
+
"""The shared code is just a STRING comparison, not a parsed
|
|
65
|
+
number - "4 7 2 9" and "fire syv to ni" are both valid as long as
|
|
66
|
+
the same words are spoken consistently every time (both when
|
|
67
|
+
configuring a device and when sending from another one). Avoids
|
|
68
|
+
needing any number-parsing at all: lowercase, collapse internal
|
|
69
|
+
whitespace to single spaces, strip. Returns None for empty/blank
|
|
70
|
+
input rather than an empty string, so callers can use a plain
|
|
71
|
+
truthy check for "is a code actually set"."""
|
|
72
|
+
if not raw:
|
|
73
|
+
return None
|
|
74
|
+
normalized = " ".join(raw.strip().lower().split())
|
|
75
|
+
return normalized or None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_name(raw):
|
|
79
|
+
"""Same normalization as normalize_code() - device names are
|
|
80
|
+
matched case-insensitively and whitespace-insensitively too
|
|
81
|
+
("Soveværelset" and "soveværelset" must be the same target)."""
|
|
82
|
+
return normalize_code(raw)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_local_ip():
|
|
86
|
+
"""Best-effort local IP detection via a UDP 'connect' that never
|
|
87
|
+
actually sends any data - just asks the OS which local address
|
|
88
|
+
and interface it would use for that destination. Works even with
|
|
89
|
+
no real internet access, as long as a route exists. A small,
|
|
90
|
+
independent reimplementation of the same idea
|
|
91
|
+
ovos-skill-network-scanner uses - not imported from there, since
|
|
92
|
+
that package registers its own 'ovos.plugin.skill' entry point;
|
|
93
|
+
depending on it would silently register network-scanner as an
|
|
94
|
+
available skill on every device that installs this one, an
|
|
95
|
+
unwanted side effect, not a reason to duplicate code lightly. See
|
|
96
|
+
DEVELOPMENT.md "Why not depend on ovos-skill-network-scanner"."""
|
|
97
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
98
|
+
try:
|
|
99
|
+
s.connect(("8.8.8.8", 80))
|
|
100
|
+
return s.getsockname()[0]
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
finally:
|
|
104
|
+
s.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------
|
|
108
|
+
# mDNS - advertise this device (once it has a configured name) so
|
|
109
|
+
# other intercom-capable devices can find its current IP/port by
|
|
110
|
+
# name, and browse for other devices doing the same. Deliberately
|
|
111
|
+
# carries NO secret in the advertised data (mDNS TXT records are
|
|
112
|
+
# broadcast in cleartext on the LAN) - the code is only ever sent
|
|
113
|
+
# inside the actual HTTP message payload, see DEVELOPMENT.md "Wire
|
|
114
|
+
# protocol". A device with a name set but no code yet still
|
|
115
|
+
# advertises - see DEVELOPMENT.md "Advertising without a code set"
|
|
116
|
+
# for why that's needed to give a specific, helpful error rather than
|
|
117
|
+
# a bare 'device not found'.
|
|
118
|
+
# ---------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def build_service_info(name, ip, port):
|
|
121
|
+
instance_name = f"{name}.{SERVICE_TYPE}"
|
|
122
|
+
return ServiceInfo(
|
|
123
|
+
SERVICE_TYPE,
|
|
124
|
+
instance_name,
|
|
125
|
+
addresses=[socket.inet_aton(ip)],
|
|
126
|
+
port=port,
|
|
127
|
+
properties={},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _PeerCollectingListener(ServiceListener):
|
|
132
|
+
"""Collects every currently-advertised intercom peer's name and
|
|
133
|
+
address as plain data - no OVOSSkill/bus access here, so this
|
|
134
|
+
stays independently testable without a running skill instance."""
|
|
135
|
+
|
|
136
|
+
def __init__(self):
|
|
137
|
+
self.found = {}
|
|
138
|
+
|
|
139
|
+
def add_service(self, zc, type_, name):
|
|
140
|
+
info = zc.get_service_info(type_, name)
|
|
141
|
+
if info is None:
|
|
142
|
+
return
|
|
143
|
+
addresses = info.parsed_addresses()
|
|
144
|
+
if not addresses:
|
|
145
|
+
return
|
|
146
|
+
peer_name = name[: -(len(SERVICE_TYPE) + 1)] if name.endswith(SERVICE_TYPE) else name
|
|
147
|
+
self.found[normalize_name(peer_name)] = {"ip": addresses[0], "port": info.port}
|
|
148
|
+
|
|
149
|
+
def remove_service(self, zc, type_, name):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def update_service(self, zc, type_, name):
|
|
153
|
+
self.add_service(zc, type_, name)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def discover_peer(target_name, timeout=3):
|
|
157
|
+
"""Browses for SERVICE_TYPE for `timeout` seconds and returns
|
|
158
|
+
{"ip": ..., "port": ...} for the peer matching target_name
|
|
159
|
+
(normalized comparison), or None if no match was found in time.
|
|
160
|
+
A fresh, short scan per call rather than a long-running cached
|
|
161
|
+
browser - intercom messages are infrequent enough that a few
|
|
162
|
+
seconds' discovery latency per send is an acceptable trade for
|
|
163
|
+
not keeping a background zeroconf browser running indefinitely."""
|
|
164
|
+
target = normalize_name(target_name)
|
|
165
|
+
if target is None:
|
|
166
|
+
return None
|
|
167
|
+
zc = Zeroconf()
|
|
168
|
+
listener = _PeerCollectingListener()
|
|
169
|
+
try:
|
|
170
|
+
ServiceBrowser(zc, SERVICE_TYPE, listener)
|
|
171
|
+
import time
|
|
172
|
+
time.sleep(timeout)
|
|
173
|
+
return listener.found.get(target)
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
|
176
|
+
finally:
|
|
177
|
+
zc.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------
|
|
181
|
+
# Wire protocol - a single HTTP POST endpoint, deliberately as narrow
|
|
182
|
+
# as possible: it accepts exactly {sender_name, code, message,
|
|
183
|
+
# reply_port} and can only ever result in a call to self.speak() on
|
|
184
|
+
# the receiving end, never anything else. Requests are handled in a
|
|
185
|
+
# background thread OUTSIDE OVOS's own event/bus thread - the handler
|
|
186
|
+
# below does ONLY the minimum safe work itself (parse, validate the
|
|
187
|
+
# code, respond) and hands off everything interactive (speaking,
|
|
188
|
+
# asking for a reply) to the skill's normal bus-driven event handling
|
|
189
|
+
# via self.bus.emit(), rather than calling self.speak_dialog()/
|
|
190
|
+
# self.get_response() directly from this thread. See DEVELOPMENT.md
|
|
191
|
+
# "Bridging a background thread into OVOS's bus" for why.
|
|
192
|
+
# ---------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def send_message(ip, port, sender_name, code, message, reply_port, timeout=5):
|
|
195
|
+
"""POSTs a message to a peer's intercom endpoint. Returns the
|
|
196
|
+
peer's status string (STATUS_OK/STATUS_NO_CODE_SET/
|
|
197
|
+
STATUS_WRONG_CODE), or None if the peer couldn't be reached at
|
|
198
|
+
all (timeout, connection refused, malformed response) - None is
|
|
199
|
+
a DIFFERENT case from any STATUS_* value and callers must
|
|
200
|
+
distinguish "unreachable" from "reachable but rejected"."""
|
|
201
|
+
import http.client
|
|
202
|
+
body = json.dumps({
|
|
203
|
+
"sender_name": sender_name,
|
|
204
|
+
"code": code,
|
|
205
|
+
"message": message,
|
|
206
|
+
"reply_port": reply_port,
|
|
207
|
+
}).encode("utf-8")
|
|
208
|
+
conn = http.client.HTTPConnection(ip, port, timeout=timeout)
|
|
209
|
+
try:
|
|
210
|
+
conn.request("POST", "/message", body=body,
|
|
211
|
+
headers={"Content-Type": "application/json"})
|
|
212
|
+
resp = conn.getresponse()
|
|
213
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
214
|
+
return data.get("status")
|
|
215
|
+
except Exception:
|
|
216
|
+
return None
|
|
217
|
+
finally:
|
|
218
|
+
conn.close()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _make_handler(skill):
|
|
222
|
+
"""Returns a BaseHTTPRequestHandler subclass bound to this skill
|
|
223
|
+
instance - a closure rather than a module-level class, since the
|
|
224
|
+
handler needs access to the skill's settings and bus, and
|
|
225
|
+
http.server.HTTPServer expects a handler CLASS (instantiated per
|
|
226
|
+
request), not an object, so state must be captured via closure
|
|
227
|
+
rather than __init__ arguments."""
|
|
228
|
+
|
|
229
|
+
class IntercomRequestHandler(BaseHTTPRequestHandler):
|
|
230
|
+
def log_message(self, fmt, *args):
|
|
231
|
+
pass # don't spam OVOS's logs with routine HTTP access lines
|
|
232
|
+
|
|
233
|
+
def do_POST(self):
|
|
234
|
+
if self.path != "/message":
|
|
235
|
+
self.send_response(404)
|
|
236
|
+
self.end_headers()
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
240
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
241
|
+
except Exception:
|
|
242
|
+
self.send_response(400)
|
|
243
|
+
self.end_headers()
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
own_code = normalize_code(skill.settings.get("intercom_code"))
|
|
247
|
+
sender_code = normalize_code(payload.get("code"))
|
|
248
|
+
|
|
249
|
+
if own_code is None:
|
|
250
|
+
status = STATUS_NO_CODE_SET
|
|
251
|
+
elif sender_code != own_code:
|
|
252
|
+
status = STATUS_WRONG_CODE
|
|
253
|
+
else:
|
|
254
|
+
status = STATUS_OK
|
|
255
|
+
|
|
256
|
+
self.send_response(200)
|
|
257
|
+
self.send_header("Content-Type", "application/json")
|
|
258
|
+
self.end_headers()
|
|
259
|
+
self.wfile.write(json.dumps({"status": status}).encode("utf-8"))
|
|
260
|
+
|
|
261
|
+
if status == STATUS_OK:
|
|
262
|
+
# Hand off to the skill's own bus thread - see the
|
|
263
|
+
# module docstring above this section for why this
|
|
264
|
+
# thread must not call self.speak_dialog()/
|
|
265
|
+
# self.get_response() itself.
|
|
266
|
+
skill.bus.emit(Message(INCOMING_MESSAGE_BUS_EVENT, {
|
|
267
|
+
"sender_name": payload.get("sender_name") or "",
|
|
268
|
+
"message": payload.get("message") or "",
|
|
269
|
+
"sender_ip": self.client_address[0],
|
|
270
|
+
"reply_port": payload.get("reply_port"),
|
|
271
|
+
}))
|
|
272
|
+
|
|
273
|
+
return IntercomRequestHandler
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class IntercomServer:
|
|
277
|
+
"""Owns the background HTTPServer thread's lifecycle - started in
|
|
278
|
+
Intercom.initialize(), stopped in Intercom.shutdown(). Binds to
|
|
279
|
+
port 0 (OS picks a free port) rather than a fixed port, since a
|
|
280
|
+
fixed port could collide with something else already running on
|
|
281
|
+
a given device, and the chosen port is advertised via mDNS anyway
|
|
282
|
+
so nothing needs to know it in advance."""
|
|
283
|
+
|
|
284
|
+
def __init__(self, skill):
|
|
285
|
+
self.skill = skill
|
|
286
|
+
self.httpd = HTTPServer(("0.0.0.0", 0), _make_handler(skill))
|
|
287
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def port(self):
|
|
291
|
+
return self.httpd.server_address[1]
|
|
292
|
+
|
|
293
|
+
def start(self):
|
|
294
|
+
self.thread.start()
|
|
295
|
+
|
|
296
|
+
def stop(self):
|
|
297
|
+
self.httpd.shutdown()
|
|
298
|
+
self.httpd.server_close()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Intercom(OVOSSkill):
|
|
302
|
+
"""LAN-only speak-only messaging - see the module docstring for
|
|
303
|
+
the non-negotiable "speaks messages, never executes commands"
|
|
304
|
+
boundary, and DEVELOPMENT.md for the fuller architecture
|
|
305
|
+
reasoning."""
|
|
306
|
+
|
|
307
|
+
def initialize(self):
|
|
308
|
+
self._server = IntercomServer(self)
|
|
309
|
+
self._server.start()
|
|
310
|
+
self.add_event(INCOMING_MESSAGE_BUS_EVENT, self._handle_incoming_message)
|
|
311
|
+
self._update_advertisement()
|
|
312
|
+
|
|
313
|
+
def shutdown(self):
|
|
314
|
+
self._unadvertise()
|
|
315
|
+
self._server.stop()
|
|
316
|
+
|
|
317
|
+
# -----------------------------------------------------------
|
|
318
|
+
# Advertising this device - re-registered whenever the name
|
|
319
|
+
# setting changes, so renaming takes effect without a restart.
|
|
320
|
+
# -----------------------------------------------------------
|
|
321
|
+
def _update_advertisement(self):
|
|
322
|
+
self._unadvertise()
|
|
323
|
+
name = normalize_name(self.settings.get("intercom_name"))
|
|
324
|
+
if name is None:
|
|
325
|
+
return # can't be addressed by voice without a name - don't advertise
|
|
326
|
+
ip = get_local_ip()
|
|
327
|
+
if ip is None:
|
|
328
|
+
return
|
|
329
|
+
self._zc = Zeroconf()
|
|
330
|
+
self._service_info = build_service_info(name, ip, self._server.port)
|
|
331
|
+
try:
|
|
332
|
+
self._zc.register_service(self._service_info)
|
|
333
|
+
except Exception:
|
|
334
|
+
self.log.exception("Failed to register intercom mDNS service")
|
|
335
|
+
|
|
336
|
+
def _unadvertise(self):
|
|
337
|
+
zc = getattr(self, "_zc", None)
|
|
338
|
+
info = getattr(self, "_service_info", None)
|
|
339
|
+
if zc is not None and info is not None:
|
|
340
|
+
try:
|
|
341
|
+
zc.unregister_service(info)
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
zc.close()
|
|
345
|
+
self._zc = None
|
|
346
|
+
self._service_info = None
|
|
347
|
+
|
|
348
|
+
# -----------------------------------------------------------
|
|
349
|
+
# Receiving - runs on the skill's normal bus thread, handed off
|
|
350
|
+
# from the HTTP background thread via self.bus.emit(). Safe to
|
|
351
|
+
# call self.speak_dialog()/self.get_response() here.
|
|
352
|
+
# -----------------------------------------------------------
|
|
353
|
+
def _handle_incoming_message(self, message):
|
|
354
|
+
sender_name = message.data.get("sender_name") or ""
|
|
355
|
+
text = message.data.get("message") or ""
|
|
356
|
+
sender_ip = message.data.get("sender_ip")
|
|
357
|
+
reply_port = message.data.get("reply_port")
|
|
358
|
+
|
|
359
|
+
self.speak_dialog("message_from", {"sender": sender_name})
|
|
360
|
+
self.speak(text)
|
|
361
|
+
|
|
362
|
+
if not sender_ip or not reply_port:
|
|
363
|
+
return # no way to send a reply even if they wanted to
|
|
364
|
+
|
|
365
|
+
wants_reply = self.ask_yesno("want_to_reply") == "yes"
|
|
366
|
+
if not wants_reply:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
self.speak_dialog("say_your_reply")
|
|
370
|
+
reply_text = self.get_response()
|
|
371
|
+
if not reply_text:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
own_name = normalize_name(self.settings.get("intercom_name")) or ""
|
|
375
|
+
own_code = normalize_code(self.settings.get("intercom_code")) or ""
|
|
376
|
+
status = send_message(sender_ip, reply_port, own_name, own_code,
|
|
377
|
+
reply_text, self._server.port)
|
|
378
|
+
if status == STATUS_OK:
|
|
379
|
+
self.speak_dialog("reply_sent")
|
|
380
|
+
else:
|
|
381
|
+
self.speak_dialog("reply_failed")
|
|
382
|
+
|
|
383
|
+
# -----------------------------------------------------------
|
|
384
|
+
# Sending
|
|
385
|
+
# -----------------------------------------------------------
|
|
386
|
+
@intent_handler("set_intercom_code.intent")
|
|
387
|
+
def handle_set_intercom_code(self, message):
|
|
388
|
+
raw = message.data.get("code")
|
|
389
|
+
code = normalize_code(raw)
|
|
390
|
+
if code is None:
|
|
391
|
+
self.speak_dialog("code_not_understood")
|
|
392
|
+
return
|
|
393
|
+
self.settings["intercom_code"] = code
|
|
394
|
+
self.speak_dialog("code_set")
|
|
395
|
+
|
|
396
|
+
@intent_handler("set_intercom_name.intent")
|
|
397
|
+
def handle_set_intercom_name(self, message):
|
|
398
|
+
raw = message.data.get("name")
|
|
399
|
+
name = normalize_name(raw)
|
|
400
|
+
if name is None:
|
|
401
|
+
self.speak_dialog("name_not_understood")
|
|
402
|
+
return
|
|
403
|
+
self.settings["intercom_name"] = name
|
|
404
|
+
self._update_advertisement()
|
|
405
|
+
self.speak_dialog("name_set", {"name": name})
|
|
406
|
+
|
|
407
|
+
@intent_handler("send_message.intent")
|
|
408
|
+
def handle_send_message(self, message):
|
|
409
|
+
own_name = normalize_name(self.settings.get("intercom_name"))
|
|
410
|
+
own_code = normalize_code(self.settings.get("intercom_code"))
|
|
411
|
+
if own_name is None:
|
|
412
|
+
self.speak_dialog("own_name_not_set")
|
|
413
|
+
return
|
|
414
|
+
if own_code is None:
|
|
415
|
+
self.speak_dialog("own_code_not_set")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
target_raw = message.data.get("target")
|
|
419
|
+
target = normalize_name(target_raw)
|
|
420
|
+
if target is None:
|
|
421
|
+
self.speak_dialog("target_not_understood")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
peer = discover_peer(target)
|
|
425
|
+
if peer is None:
|
|
426
|
+
self.speak_dialog("target_not_found", {"target": target_raw})
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
self.speak_dialog("what_to_say")
|
|
430
|
+
text = self.get_response()
|
|
431
|
+
if not text:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
status = send_message(peer["ip"], peer["port"], own_name, own_code,
|
|
435
|
+
text, self._server.port)
|
|
436
|
+
if status == STATUS_OK:
|
|
437
|
+
self.speak_dialog("message_sent")
|
|
438
|
+
elif status == STATUS_NO_CODE_SET:
|
|
439
|
+
self.speak_dialog("target_no_code_set", {"target": target_raw})
|
|
440
|
+
elif status == STATUS_WRONG_CODE:
|
|
441
|
+
self.speak_dialog("target_code_mismatch", {"target": target_raw})
|
|
442
|
+
else:
|
|
443
|
+
self.speak_dialog("target_unreachable", {"target": target_raw})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Generates icon.png - 512x512, rounded-rect background (radius 90),
|
|
2
|
+
white line-art of a doorbell/intercom speaker grille - matching the
|
|
3
|
+
style of the other skills' icons."""
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw
|
|
6
|
+
|
|
7
|
+
SIZE = 512
|
|
8
|
+
BG_COLOR = (196, 90, 40, 255) # warm terracotta, distinct from siblings
|
|
9
|
+
WHITE = (255, 255, 255, 255)
|
|
10
|
+
|
|
11
|
+
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
|
12
|
+
draw = ImageDraw.Draw(img)
|
|
13
|
+
draw.rounded_rectangle([0, 0, SIZE - 1, SIZE - 1], radius=90, fill=BG_COLOR)
|
|
14
|
+
|
|
15
|
+
# Outer intercom panel
|
|
16
|
+
panel = [136, 96, 376, 416]
|
|
17
|
+
draw.rounded_rectangle(panel, radius=28, outline=WHITE, width=14)
|
|
18
|
+
|
|
19
|
+
# Speaker grille - rows of small horizontal slits
|
|
20
|
+
grille_left, grille_right = 170, 342
|
|
21
|
+
slit_h = 10
|
|
22
|
+
slit_gap = 14
|
|
23
|
+
slit_top = 140
|
|
24
|
+
for i in range(6):
|
|
25
|
+
y0 = slit_top + i * (slit_h + slit_gap)
|
|
26
|
+
draw.rounded_rectangle([grille_left, y0, grille_right, y0 + slit_h],
|
|
27
|
+
radius=5, fill=WHITE)
|
|
28
|
+
|
|
29
|
+
# Single call button below the grille
|
|
30
|
+
button_cx, button_cy, button_r = 256, 360, 26
|
|
31
|
+
draw.ellipse([button_cx - button_r, button_cy - button_r,
|
|
32
|
+
button_cx + button_r, button_cy + button_r],
|
|
33
|
+
outline=WHITE, width=10)
|
|
34
|
+
|
|
35
|
+
img.save("icon.png")
|
|
36
|
+
print("wrote icon.png", img.size, img.mode)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
undskyld, jeg forstod ikke koden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
intercom-kode er sat
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
besked fra {sender}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
besked sendt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
undskyld, jeg forstod ikke navnet
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
denne enhed hedder nu {name} til intercom
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
denne enhed har ikke en intercom-kode endnu. Sig "sæt intercom-kode til" efterfulgt af en kode
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
denne enhed har ikke et intercom-navn endnu. Sig "sæt intercom-navn til" efterfulgt af et navn til enheden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jeg kunne ikke sende dit svar
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
svar sendt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sig dit svar
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sæt intercom-kode til {code}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_id": "ovos-skill-intercom.andlo",
|
|
3
|
+
"source": "https://github.com/andlo/ovos-skill-intercom",
|
|
4
|
+
"package_name": "ovos-skill-intercom",
|
|
5
|
+
"pip_spec": "ovos-skill-intercom",
|
|
6
|
+
"license": "GPL-3.0-or-later",
|
|
7
|
+
"author": "andlo",
|
|
8
|
+
"name": "Intercom",
|
|
9
|
+
"description": "LAN-intercom - send en talt besked til en navngiven OVOS-enhed på samme netværk. Kun tale, udfører aldrig kommandoer på modtager-enheden.",
|
|
10
|
+
"examples": [
|
|
11
|
+
"sæt intercom-navn til stuen",
|
|
12
|
+
"sæt intercom-kode til fire syv to ni",
|
|
13
|
+
"send en besked til soveværelset"
|
|
14
|
+
],
|
|
15
|
+
"tags": [
|
|
16
|
+
"intercom",
|
|
17
|
+
"multi-device",
|
|
18
|
+
"besked",
|
|
19
|
+
"lan"
|
|
20
|
+
],
|
|
21
|
+
"icon": "https://raw.githubusercontent.com/andlo/ovos-skill-intercom/main/icon.png"
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{target} har en anden intercom-kode end din. Sørg for at samme kode er sat på begge enheder
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{target} har ikke en intercom-kode sat endnu. Sig "sæt intercom-kode til" efterfulgt af en kode, på enheden der hedder {target}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jeg kan ikke finde en enhed der hedder {target} på netværket
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
undskyld, hvilken enhed mente du?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jeg kunne ikke få kontakt med {target}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ønsker du at svare?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hvad skal jeg sige?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
entschuldigung, den code habe ich nicht verstanden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
intercom-code ist gesetzt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nachricht von {sender}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nachricht gesendet
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
entschuldigung, den namen habe ich nicht verstanden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dieses gerät heißt jetzt {name} für intercom
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dieses gerät hat noch keinen intercom-code. Sage "setze intercom-code auf" gefolgt von einem code
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dieses gerät hat noch keinen intercom-namen. Sage "setze intercom-namen auf" gefolgt von einem namen für dieses gerät
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ich konnte deine antwort nicht senden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
antwort gesendet
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sag deine antwort
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
setze intercom-code auf {code}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_id": "ovos-skill-intercom.andlo",
|
|
3
|
+
"source": "https://github.com/andlo/ovos-skill-intercom",
|
|
4
|
+
"package_name": "ovos-skill-intercom",
|
|
5
|
+
"pip_spec": "ovos-skill-intercom",
|
|
6
|
+
"license": "GPL-3.0-or-later",
|
|
7
|
+
"author": "andlo",
|
|
8
|
+
"name": "Intercom",
|
|
9
|
+
"description": "LAN-Intercom - sendet eine gesprochene Nachricht an ein benanntes OVOS-Gerät im selben Netzwerk. Nur Sprache, führt niemals Befehle auf dem Empfängergerät aus.",
|
|
10
|
+
"examples": [
|
|
11
|
+
"setze intercom-namen auf wohnzimmer",
|
|
12
|
+
"setze intercom-code auf vier sieben zwei neun",
|
|
13
|
+
"sende eine nachricht an schlafzimmer"
|
|
14
|
+
],
|
|
15
|
+
"tags": [
|
|
16
|
+
"intercom",
|
|
17
|
+
"multi-device",
|
|
18
|
+
"nachricht",
|
|
19
|
+
"lan"
|
|
20
|
+
],
|
|
21
|
+
"icon": "https://raw.githubusercontent.com/andlo/ovos-skill-intercom/main/icon.png"
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{target} hat einen anderen intercom-code als du. Stelle sicher, dass auf beiden geräten derselbe code gesetzt ist
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{target} hat noch keinen intercom-code gesetzt. Sage "setze intercom-code auf" gefolgt von einem code, auf dem gerät namens {target}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ich kann kein gerät namens {target} im netzwerk finden
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
entschuldigung, welches gerät meintest du?
|