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.
Files changed (119) hide show
  1. ovos_skill_intercom/__init__.py +443 -0
  2. ovos_skill_intercom/build_icon.py +36 -0
  3. ovos_skill_intercom/locale/da-dk/code_not_understood.dialog +1 -0
  4. ovos_skill_intercom/locale/da-dk/code_set.dialog +1 -0
  5. ovos_skill_intercom/locale/da-dk/message_from.dialog +1 -0
  6. ovos_skill_intercom/locale/da-dk/message_sent.dialog +1 -0
  7. ovos_skill_intercom/locale/da-dk/name_not_understood.dialog +1 -0
  8. ovos_skill_intercom/locale/da-dk/name_set.dialog +1 -0
  9. ovos_skill_intercom/locale/da-dk/own_code_not_set.dialog +1 -0
  10. ovos_skill_intercom/locale/da-dk/own_name_not_set.dialog +1 -0
  11. ovos_skill_intercom/locale/da-dk/reply_failed.dialog +1 -0
  12. ovos_skill_intercom/locale/da-dk/reply_sent.dialog +1 -0
  13. ovos_skill_intercom/locale/da-dk/say_your_reply.dialog +1 -0
  14. ovos_skill_intercom/locale/da-dk/send_message.intent +3 -0
  15. ovos_skill_intercom/locale/da-dk/set_intercom_code.intent +1 -0
  16. ovos_skill_intercom/locale/da-dk/set_intercom_name.intent +2 -0
  17. ovos_skill_intercom/locale/da-dk/skill.json +22 -0
  18. ovos_skill_intercom/locale/da-dk/target_code_mismatch.dialog +1 -0
  19. ovos_skill_intercom/locale/da-dk/target_no_code_set.dialog +1 -0
  20. ovos_skill_intercom/locale/da-dk/target_not_found.dialog +1 -0
  21. ovos_skill_intercom/locale/da-dk/target_not_understood.dialog +1 -0
  22. ovos_skill_intercom/locale/da-dk/target_unreachable.dialog +1 -0
  23. ovos_skill_intercom/locale/da-dk/want_to_reply.dialog +1 -0
  24. ovos_skill_intercom/locale/da-dk/what_to_say.dialog +1 -0
  25. ovos_skill_intercom/locale/de-de/code_not_understood.dialog +1 -0
  26. ovos_skill_intercom/locale/de-de/code_set.dialog +1 -0
  27. ovos_skill_intercom/locale/de-de/message_from.dialog +1 -0
  28. ovos_skill_intercom/locale/de-de/message_sent.dialog +1 -0
  29. ovos_skill_intercom/locale/de-de/name_not_understood.dialog +1 -0
  30. ovos_skill_intercom/locale/de-de/name_set.dialog +1 -0
  31. ovos_skill_intercom/locale/de-de/own_code_not_set.dialog +1 -0
  32. ovos_skill_intercom/locale/de-de/own_name_not_set.dialog +1 -0
  33. ovos_skill_intercom/locale/de-de/reply_failed.dialog +1 -0
  34. ovos_skill_intercom/locale/de-de/reply_sent.dialog +1 -0
  35. ovos_skill_intercom/locale/de-de/say_your_reply.dialog +1 -0
  36. ovos_skill_intercom/locale/de-de/send_message.intent +3 -0
  37. ovos_skill_intercom/locale/de-de/set_intercom_code.intent +1 -0
  38. ovos_skill_intercom/locale/de-de/set_intercom_name.intent +2 -0
  39. ovos_skill_intercom/locale/de-de/skill.json +22 -0
  40. ovos_skill_intercom/locale/de-de/target_code_mismatch.dialog +1 -0
  41. ovos_skill_intercom/locale/de-de/target_no_code_set.dialog +1 -0
  42. ovos_skill_intercom/locale/de-de/target_not_found.dialog +1 -0
  43. ovos_skill_intercom/locale/de-de/target_not_understood.dialog +1 -0
  44. ovos_skill_intercom/locale/de-de/target_unreachable.dialog +1 -0
  45. ovos_skill_intercom/locale/de-de/want_to_reply.dialog +1 -0
  46. ovos_skill_intercom/locale/de-de/what_to_say.dialog +1 -0
  47. ovos_skill_intercom/locale/en-us/code_not_understood.dialog +1 -0
  48. ovos_skill_intercom/locale/en-us/code_set.dialog +1 -0
  49. ovos_skill_intercom/locale/en-us/message_from.dialog +1 -0
  50. ovos_skill_intercom/locale/en-us/message_sent.dialog +1 -0
  51. ovos_skill_intercom/locale/en-us/name_not_understood.dialog +1 -0
  52. ovos_skill_intercom/locale/en-us/name_set.dialog +1 -0
  53. ovos_skill_intercom/locale/en-us/own_code_not_set.dialog +1 -0
  54. ovos_skill_intercom/locale/en-us/own_name_not_set.dialog +1 -0
  55. ovos_skill_intercom/locale/en-us/reply_failed.dialog +1 -0
  56. ovos_skill_intercom/locale/en-us/reply_sent.dialog +1 -0
  57. ovos_skill_intercom/locale/en-us/say_your_reply.dialog +1 -0
  58. ovos_skill_intercom/locale/en-us/send_message.intent +3 -0
  59. ovos_skill_intercom/locale/en-us/set_intercom_code.intent +1 -0
  60. ovos_skill_intercom/locale/en-us/set_intercom_name.intent +2 -0
  61. ovos_skill_intercom/locale/en-us/skill.json +22 -0
  62. ovos_skill_intercom/locale/en-us/target_code_mismatch.dialog +1 -0
  63. ovos_skill_intercom/locale/en-us/target_no_code_set.dialog +1 -0
  64. ovos_skill_intercom/locale/en-us/target_not_found.dialog +1 -0
  65. ovos_skill_intercom/locale/en-us/target_not_understood.dialog +1 -0
  66. ovos_skill_intercom/locale/en-us/target_unreachable.dialog +1 -0
  67. ovos_skill_intercom/locale/en-us/want_to_reply.dialog +1 -0
  68. ovos_skill_intercom/locale/en-us/what_to_say.dialog +1 -0
  69. ovos_skill_intercom/locale/es-es/code_not_understood.dialog +1 -0
  70. ovos_skill_intercom/locale/es-es/code_set.dialog +1 -0
  71. ovos_skill_intercom/locale/es-es/message_from.dialog +1 -0
  72. ovos_skill_intercom/locale/es-es/message_sent.dialog +1 -0
  73. ovos_skill_intercom/locale/es-es/name_not_understood.dialog +1 -0
  74. ovos_skill_intercom/locale/es-es/name_set.dialog +1 -0
  75. ovos_skill_intercom/locale/es-es/own_code_not_set.dialog +1 -0
  76. ovos_skill_intercom/locale/es-es/own_name_not_set.dialog +1 -0
  77. ovos_skill_intercom/locale/es-es/reply_failed.dialog +1 -0
  78. ovos_skill_intercom/locale/es-es/reply_sent.dialog +1 -0
  79. ovos_skill_intercom/locale/es-es/say_your_reply.dialog +1 -0
  80. ovos_skill_intercom/locale/es-es/send_message.intent +2 -0
  81. ovos_skill_intercom/locale/es-es/set_intercom_code.intent +1 -0
  82. ovos_skill_intercom/locale/es-es/set_intercom_name.intent +2 -0
  83. ovos_skill_intercom/locale/es-es/skill.json +22 -0
  84. ovos_skill_intercom/locale/es-es/target_code_mismatch.dialog +1 -0
  85. ovos_skill_intercom/locale/es-es/target_no_code_set.dialog +1 -0
  86. ovos_skill_intercom/locale/es-es/target_not_found.dialog +1 -0
  87. ovos_skill_intercom/locale/es-es/target_not_understood.dialog +1 -0
  88. ovos_skill_intercom/locale/es-es/target_unreachable.dialog +1 -0
  89. ovos_skill_intercom/locale/es-es/want_to_reply.dialog +1 -0
  90. ovos_skill_intercom/locale/es-es/what_to_say.dialog +1 -0
  91. ovos_skill_intercom/locale/fr-fr/code_not_understood.dialog +1 -0
  92. ovos_skill_intercom/locale/fr-fr/code_set.dialog +1 -0
  93. ovos_skill_intercom/locale/fr-fr/message_from.dialog +1 -0
  94. ovos_skill_intercom/locale/fr-fr/message_sent.dialog +1 -0
  95. ovos_skill_intercom/locale/fr-fr/name_not_understood.dialog +1 -0
  96. ovos_skill_intercom/locale/fr-fr/name_set.dialog +1 -0
  97. ovos_skill_intercom/locale/fr-fr/own_code_not_set.dialog +1 -0
  98. ovos_skill_intercom/locale/fr-fr/own_name_not_set.dialog +1 -0
  99. ovos_skill_intercom/locale/fr-fr/reply_failed.dialog +1 -0
  100. ovos_skill_intercom/locale/fr-fr/reply_sent.dialog +1 -0
  101. ovos_skill_intercom/locale/fr-fr/say_your_reply.dialog +1 -0
  102. ovos_skill_intercom/locale/fr-fr/send_message.intent +2 -0
  103. ovos_skill_intercom/locale/fr-fr/set_intercom_code.intent +1 -0
  104. ovos_skill_intercom/locale/fr-fr/set_intercom_name.intent +2 -0
  105. ovos_skill_intercom/locale/fr-fr/skill.json +22 -0
  106. ovos_skill_intercom/locale/fr-fr/target_code_mismatch.dialog +1 -0
  107. ovos_skill_intercom/locale/fr-fr/target_no_code_set.dialog +1 -0
  108. ovos_skill_intercom/locale/fr-fr/target_not_found.dialog +1 -0
  109. ovos_skill_intercom/locale/fr-fr/target_not_understood.dialog +1 -0
  110. ovos_skill_intercom/locale/fr-fr/target_unreachable.dialog +1 -0
  111. ovos_skill_intercom/locale/fr-fr/want_to_reply.dialog +1 -0
  112. ovos_skill_intercom/locale/fr-fr/what_to_say.dialog +1 -0
  113. ovos_skill_intercom/version.py +5 -0
  114. ovos_skill_intercom-0.0.1.dist-info/METADATA +146 -0
  115. ovos_skill_intercom-0.0.1.dist-info/RECORD +119 -0
  116. ovos_skill_intercom-0.0.1.dist-info/WHEEL +5 -0
  117. ovos_skill_intercom-0.0.1.dist-info/entry_points.txt +2 -0
  118. ovos_skill_intercom-0.0.1.dist-info/licenses/LICENSE +674 -0
  119. 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,3 @@
1
+ send en besked til {target}
2
+ send besked til {target}
3
+ send en intercom-besked til {target}
@@ -0,0 +1 @@
1
+ sæt intercom-kode til {code}
@@ -0,0 +1,2 @@
1
+ sæt intercom-navn til {name}
2
+ kald denne enhed {name} til intercom
@@ -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,3 @@
1
+ sende eine nachricht an {target}
2
+ schicke eine nachricht an {target}
3
+ sende eine intercom-nachricht an {target}
@@ -0,0 +1 @@
1
+ setze intercom-code auf {code}
@@ -0,0 +1,2 @@
1
+ setze intercom-namen auf {name}
2
+ nenne dieses gerät {name} für intercom
@@ -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?