Add myintercom doorbell

This commit is contained in:
Jakob Lechner 2023-11-22 14:49:54 +00:00
parent 4aa948f8d4
commit aba0d00afd
No known key found for this signature in database
GPG key ID: 996082EFB5906C10
19 changed files with 764 additions and 2 deletions

View file

@ -1,5 +1,6 @@
duckdns-secret: ENC[AES256_GCM,data:hp4aWnmTYKZhBehY0nuRV+H9bpCdK2uNqY3J0s1w6JsiyXip,iv:X0MtN+lqDqucgHOgS1D/RrMksNydLFW1/wqD47DWhqQ=,tag:+7qsJEJYzI+UUrdC6NZr4Q==,type:str]
pap-secrets: ENC[AES256_GCM,data:UyC63/4EXZjypFlH7MLtJXpIBgD9P/Eolg2M1A==,iv:tf8W8rpRa487PIB9NW4NyDKgCoWYV/wDgs9MmKLZ/mc=,tag:r+zgW8XI9TUyoz56irYEdQ==,type:str]
myintercom-doorbell-password: ENC[AES256_GCM,data:waUUvHQ9BZFePQ==,iv:ev21SNOwzdNMc3Opo6Kgdd7daLNUf9e1/C9RtxQKV8U=,tag:aOi2f3VuR49J0sHNrVXW/A==,type:str]
asterisk-pjsip: ENC[AES256_GCM,data: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,iv:bFKc8e+3rLAHje8UWwY2elof5xqceTTWX1f7nkE91nM=,tag:NWMiljj8urTDoka5bkF0jg==,type:str]
asterisk-ari: ENC[AES256_GCM,data:HnY7d3BdScb0bmsBVlsTHAMv2k8tyyA/,iv:q+NsCHcGGOCe6gdAHbFfjKvO4dyWoW/xI5jtngJmdds=,tag:e8kuEsEokf5lAAgO/coxTQ==,type:str]
asterisk-voicemail: ENC[AES256_GCM,data:uyXeBP+9WkfVot4Ot3vwv3OEZfoVDK2I+lvaPpGJTZp16YNtP+uxNiW2ynewQlORCTY59bP1jW3bQdT/ASGsErOrhInYSytTyfdZ51BF9+jz0TH6oWxsSuuawTrkC8jvJOpejt6XuGoYbbqlM/VL1xzgDkq3ztTxaHTfdTonQij2Q4cYddMRHWIEuBCK7FU2TlHAJeIFZvtE0MiyNNT3rEWSs1xcljTGfMjkoMd+FI1uZSQT4r0kAaPPkvCWcAGH6R+F0Ue++i9TuLhu+sDV+X6u3N/garDW74H0bOcLJysImtuPXh1aXuBkHQuC1Liss/IF4NDjtDDhpfc0eePR5MWv/Kj0q+VFJiUPY6XnWh6fG9I2yY22+I7eAAg/xWVZBXPWbFHRz8jm1owp4ln6/hcrJOw6Fzw8tZ6Jd9nciOeOmR1KtjEzklPP5kP1YQPtGio/LnOaAAhTHy16MbWf/Ey4S30+eHB+joD8OM93+YxxrdKNE6XXEcAhkdpHYecrvz4Co1fhY7ZoOnNvA8Juup/7PMyNEU/Fy4Pta34aT/j1s7de2vTpRNBeecWvgFA9Qd7Re/2XPqOAkpduxDniwsUdb52oL39MBoOCY8brmXn2J/mMDeOmoqvjRHsPZsajPTAqF/nqRB8VpwoZAKAx59DYBGgmHz7/7JRX9NXOAus1yLbMfVqDftk6+KTFQ9wCqei3jaI/K5AJrSEwlZG0BLoDefIGXT5f8bNNgSn865j2RP+FLa6W3/u5t+k=,iv:/phktIxMdDO5Nrum7hf3oLDmQO04lrkvFuHNw77aRks=,tag:7OUg0BG9X7nBHWiQNaSOEQ==,type:str]
@ -18,8 +19,8 @@ sops:
MU41eU8zeTRRUlZyUXV0U1N6U0NRNnMKZK3vfyRRr7Iu6HfpdpmDTKzUbEnCnW9l
rGjFmY9VX2q9w3j/4E5uUToQfeGMqqBTOFUB3hNgU8K5ZT7wMbOXAg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2023-10-29T11:47:51Z"
mac: ENC[AES256_GCM,data:Ydzfs57nFEAOIs5TonA6cP+btYiDXoVD08F1dQMXIAudw/I1svuO39so4pDglrzvNRraUyvMhSuwfDOGkjWT2AE06UBL7KQOYPDiZWasg42N6YORfMFDr2MOrhcaVZ1y3dad8wAEpuNySbXIi5LGy366UEgKGHkrxQ6RdD84NYA=,iv:BZQ6pn2Tvpy6QWR+HWn1KBDoiuwDjlixbFnDTXZN5RU=,tag:4XeYUfS0cT+PvZ+Julo3/A==,type:str]
lastmodified: "2023-11-06T23:32:51Z"
mac: ENC[AES256_GCM,data:7lW6i4ULus4348NwnV/ovcWebspBcEBzYqLtl+8xFOfe3erIFnC3iRo0ibZJ8yishZpIUxoVu08yxQoa1qEriC57WETMaR+iGUPaY75tHraBJGY26Etk7Hy2QhQ7D+srBY+CogHhHAD8HUwT4/ZiPqKe1eQAvNg/6HWnjbQkG/Q=,iv:r43odkYgQsyK5uJJ5V98kTx7enP7TRuFoTnYfHmD/8o=,tag:hR+1zCniHs1l3qSkhQhtFw==,type:str]
pgp:
- created_at: "2022-11-02T22:14:19Z"
enc: |-

View file

@ -15,6 +15,16 @@ in
enable = true;
confFiles = {
"extensions.conf" = ''
[doorbell]
exten = s,1,Set(__DYNAMIC_FEATURES=doorOpen)
same = n,Dial(PJSIP/10&PJSIP/11,${toString config.services.myintercom-doorbell.dialTime})
same = n,Hangup()
[door-open]
exten = s,1,Verbose(0, "opening the door")
same = n,System("${pkgs.myintercom-doorbell}/bin/myintercom-doorbell-open-door")
same = n,Hangup()
[sipgate-in]
exten = _499846876,1,Noop(Processing an incoming call)
same = n,Dial(PJSIP/10&PJSIP/11,25,tT)
@ -109,6 +119,10 @@ in
; Send the fax
exten => send,n,SendFAX(/home/jalr/fax.tif,d)
'';
"features.conf" = ''
[applicationmap]
doorOpen => #9,peer,Gosub,"door-open,s,1"
'';
"http.conf" = ''
[general]
enabled=yes

View file

@ -2,6 +2,7 @@
imports = [
./asterisk.nix
./dnsmasq.nix
./doorbell.nix
./dyndns.nix
./unifi-controller.nix
];

View file

@ -0,0 +1,19 @@
{ config, lib, pkgs, ... }:
{
sops.secrets.myintercom-doorbell-password = {
sopsFile = ../secrets.yaml;
owner = "asterisk";
};
services.myintercom-doorbell = {
enable = true;
host = "192.168.0.74";
username = "btxpvt0002";
passwordFile = config.sops.secrets.myintercom-doorbell-password.path;
audiosocket = {
address = "127.0.0.1";
port = 9092;
uuid = "4960ab41-dbef-4773-a25e-90536d97345e";
};
callerId = "Sprechanlage";
};
}

View file

@ -13,6 +13,7 @@ in
docker-machine-gitlab = callPackage ./docker-machine-gitlab { };
fpvout = callPackage ./fpvout { };
mute-indicator = callPackage ./mute-indicator { };
myintercom-doorbell = callPackage ./myintercom-doorbell { };
pretix = callPackage ./pretix/pretix.nix { };
pretix-banktool = callPackage ./pretix/pretix-banktool.nix { };
pretix-static = callPackage ./pretix/pretix-static.nix { };

View file

@ -2,6 +2,7 @@
{
imports = [
./myintercom-doorbell/module.nix
./pretix/module.nix
];
}

View file

@ -0,0 +1 @@
use nix

1
pkgs/myintercom-doorbell/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/dist

View file

View file

@ -0,0 +1,12 @@
{ lib, python3, poetry2nix }:
poetry2nix.mkPoetryApplication rec {
pname = "myintercom-audiosocket";
version = "0.0.1";
projectDir = ./.;
overrides = poetry2nix.overrides.withDefaults (final: prev: {
urllib3 = prev.urllib3.override {
preferWheel = true;
};
});
}

View file

@ -0,0 +1,99 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.myintercom-doorbell;
in
{
options.services.myintercom-doorbell = with lib; with lib.types; {
enable = mkEnableOption "Enable myintercom service";
host = mkOption {
type = types.str;
description = "The Hostname of myintercom.";
example = "myintercom.lan.example.net";
};
username = mkOption {
type = types.str;
description = "Username for basic auth.";
};
passwordFile = mkOption {
type = types.path;
description = "Path to the file that contains the basic auth password.";
};
audiosocket = {
address = mkOption {
type = types.str;
description = "Address the AudioSocket binds to.";
default = "127.0.0.1";
};
port = mkOption {
type = types.port;
description = "Port the AudioSocket binds to.";
default = 9092;
};
uuid = mkOption {
type = types.str;
example = "e461837f-22b0-4652-955f-e1a444f3a42e";
};
};
callerId = mkOption {
type = types.str;
description = "The display name to show when the doorbell rings a phone.";
example = "Doorbell";
};
dialTime = mkOption {
type = types.int;
description = "The duration how long to wait for the call to be answered.";
default = 45;
};
};
config = lib.mkIf cfg.enable {
environment.etc."myintercom-doorbell/settings.json".text = builtins.toJSON {
host = cfg.host;
username = cfg.username;
passwordFile = cfg.passwordFile;
audiosocket = {
address = cfg.audiosocket.address;
port = cfg.audiosocket.port;
uuid = cfg.audiosocket.uuid;
};
callerId = cfg.callerId;
dialTime = cfg.dialTime;
};
systemd.services.myintercom-doorbell-poll = {
enable = cfg.enable;
description = "Polls myintercom doorbell ring button status.";
after = [ "asterisk.service" ];
serviceConfig = {
Type = "simple";
User = "asterisk";
ExecStart = "${pkgs.myintercom-doorbell}/bin/myintercom-doorbell-poll";
};
};
systemd.services.myintercom-doorbell-audiosocket = {
enable = cfg.enable;
description = "myintercom doorbell AudioSocket for Asterisk";
requires = [ "asterisk.service" ];
serviceConfig = {
Type = "simple";
DynamicUser = true;
CapabilityBoundingSet = null;
PrivateUsers = true;
ProtectHome = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
SystemCallFilter = "@system-service";
LoadCredential = "password:${cfg.passwordFile}";
Environment = [
"LISTEN_ADDRESS=${cfg.audiosocket.address}"
"LISTEN_PORT=${toString cfg.audiosocket.port}"
"USERNAME=${cfg.username}"
"PASSWORD_FILE=%d/password"
];
ExecStart = "${pkgs.myintercom-doorbell}/bin/myintercom-doorbell-audiosocket";
};
};
};
}

View file

@ -0,0 +1,83 @@
import socket
from threading import Thread
from dataclasses import dataclass
from .connection import Connection
@dataclass
class audioop_struct:
ratecv_state: None
rate: int
channels: int
ulaw2lin: bool
lin2ulaw: bool
# Make a single, global object instance,
# then loop with listen() method alone where needed
# Creates a new audiosocket object
class Audiosocket:
def __init__(self, bind_info, timeout=None):
# By default, features of audioop (for example: resampling
# or re-mixng input/output) are disabled
self.user_resample = None
self.asterisk_resample = None
if not isinstance(bind_info, tuple):
raise TypeError("Expected tuple (addr, port), received", type(bind_info))
self.addr, self.port = bind_info
self.initial_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.initial_sock.bind((self.addr, self.port))
self.initial_sock.settimeout(timeout)
self.initial_sock.listen(1)
# If the user didn't specify a port, the one that the operating system
# chose is availble in this attribute
self.port = self.initial_sock.getsockname()[1]
# Optionally prepares audio sent by the user to
# the specifications needed by audiosocket (16-bit, 8KHz mono LE PCM).
# Audio sent in must be in PCM or ULAW format
def prepare_input(self, inrate=44000, channels=2, ulaw2lin=False, lin2ulaw=False):
self.user_resample = audioop_struct(
rate=inrate,
channels=channels,
ulaw2lin=ulaw2lin,
lin2ulaw=lin2ulaw,
ratecv_state=None,
)
# Optionally prepares audio sent by audiosocket to
# the specifications of the user
def prepare_output(self, outrate=44000, channels=2, ulaw2lin=False, lin2ulaw=False):
self.asterisk_resample = audioop_struct(
rate=outrate,
channels=channels,
ulaw2lin=ulaw2lin,
lin2ulaw=lin2ulaw,
ratecv_state=None,
)
def listen(self):
conn, peer_addr = self.initial_sock.accept()
connection = Connection(
conn,
peer_addr,
self.user_resample,
self.asterisk_resample,
)
connection_thread = Thread(target=connection._process, args=())
connection_thread.start()
return connection
# If we want this single object to serve multiple simultaneous
# connections, accept() will have to be put in a while loop
# If this does become the case, what is the best way to deliver the
# queue objects to the caller, keep them wrapped in read/write methods?

View file

@ -0,0 +1,245 @@
# Standard Python modules
import audioop
from queue import Queue, Empty
from dataclasses import dataclass
from threading import Lock
from time import sleep
# A sort of imitation struct that holds all of the possible
# AudioSocket message types
@dataclass(frozen=True)
class types_struct:
uuid: bytes = b"\x01" # Message payload contains UUID set in Asterisk Dialplan
audio: bytes = b"\x10" # * Message payload contains 8Khz 16-bit mono LE PCM audio (* See Github readme)
silence: bytes = b"\x02" # Message payload contains silence (I've never seen this occur personally)
hangup: bytes = b"\x00" # Tell Asterisk to hangup the call (This doesn't appear to ever be sent from Asterisk to us)
error: bytes = b"\xff" # Message payload contains an error from Asterisk
types = types_struct()
# The size of 20ms of 8KHz 16-bit mono LE PCM represented as a
# 16 bit (2 byte, size of length header) unsigned BE integer
# This amount of the audio data mentioned above is equal
# to 320 bytes, which is the required payload size when
# sending audio back to AudioSocket for playback on the
# bridged channel. Sending more or less data will result in distorted sound
PCM_SIZE = (320).to_bytes(2, "big")
# Similar to one above, this holds all the possible
# AudioSocket related error codes Asterisk can send us
@dataclass(frozen=True)
class errors_struct:
none: bytes = b"\x00"
hangup: bytes = b"\x01"
frame: bytes = b"\x02"
memory: bytes = b"\x04"
errors = errors_struct()
class Connection:
def __init__(self, conn, peer_addr, user_resample, asterisk_resample):
self.conn = conn
self.peer_addr = peer_addr
self.uuid = None
self.connected = True # An instance gets created because a connection occurred
self._user_resample = user_resample
self._asterisk_resample = asterisk_resample
# Underlying Queue objects for passing incoming/outgoing audio between threads
self._rx_q = Queue(500)
self._tx_q = Queue(500)
self._lock = Lock()
# Splits data sent by AudioSocket into three different peices
def _split_data(self, data):
if len(data) < 3:
print(
"[AUDIOSOCKET WARNING] The data received was less than 3 bytes, "
+ "the minimum length data from Asterisk AudioSocket should be."
)
return b"\x00", 0, bytes(320)
else:
# type length payload
return data[:1], int.from_bytes(data[1:3], "big"), data[3:]
# If the type of message received was an error, this
# prints an explanation of the specific one that occurred
def _decode_error(self, payload):
if payload == errors.none:
print("[ASTERISK ERROR] No error code present")
elif payload == errors.hangup:
print("[ASTERISK ERROR] The called party hungup")
elif payload == errors.frame:
print("[ASTERISK ERROR] Failed to forward frame")
elif payload == errors.memory:
print("[ASTERISK ERROR] Memory allocation error")
return
# Gets AudioSocket audio from the rx queue
def read(self):
try:
audio = self._rx_q.get(timeout=0.2)
# If for some reason we receive less than 320 bytes
# of audio, add silence (padding) to the end. This prevents
# audioop related errors that are caused by the current frame
# not being the same size as the last
if len(audio) != 320:
audio += bytes(320 - len(audio))
except Empty:
return bytes(320)
if self._asterisk_resample:
# If AudioSocket is bridged with a channel
# using the ULAW audio codec, the user can specify
# to have it converted to linear encoding upon reading.
if self._asterisk_resample.ulaw2lin:
audio = audioop.ulaw2lin(audio, 2)
if self._asterisk_resample.lin2ulaw:
audio = audioop.lin2ulaw(audio, 2)
# If the user requested an outrate different
# from the default, then resample it to the rate they specified
if self._asterisk_resample.rate != 8000:
audio, self._asterisk_resample.ratecv_state = audioop.ratecv(
audio,
2,
1,
8000,
self._asterisk_resample.rate,
self._asterisk_resample.ratecv_state,
)
# If the user requested the output be in stereo,
# then convert it from mono
if self._asterisk_resample.channels == 2:
audio = audioop.tostereo(audio, 2, 1, 1)
return audio
# Puts user supplied audio into the tx queue
def write(self, audio):
if self._user_resample:
# The user can also specify to have ULAW encoded source audio
# converted to linear encoding upon being written.
if self._user_resample.ulaw2lin:
# Possibly skip downsampling if this was triggered, as
# while ULAW encoded audio can be sampled at rates other
# than 8KHz, since this is telephony related, it's unlikely.
audio = audioop.ulaw2lin(audio, 2)
if self._user_resample.lin2ulaw:
audio = audioop.lin2ulaw(audio, 2)
# If the audio isn't already sampled at 8KHz,
# then it needs to be downsampled first
if self._user_resample.rate != 8000:
audio, self._user_resample.ratecv_state = audioop.ratecv(
audio,
2,
self._user_resample.channels,
self._user_resample.rate,
8000,
self._user_resample.ratecv_state,
)
# If the audio isn't already in mono, then
# it needs to be downmixed as well
if self._user_resample.channels == 2:
audio = audioop.tomono(audio, 2, 1, 1)
self._tx_q.put(audio)
# *** This may interfere with the thread executing _process, consider
# sending type through queue as well, so a hangup message can be done properly
# Tells Asterisk to hangup the call from it's end.
# Although after the call is hungup, the socket on Asterisk's end
# closes the connection via an abrupt RST packet, resulting in a "Connection reset by peer"
# error on our end. Unfortunately, using try and except around self.conn.recv() is as
# clean as I think it can be right now
def hangup(self):
# Three bytes of 0 indicate a hangup message
with self._lock:
self.conn.send(types.hangup * 3)
sleep(0.2)
return
def _process(self):
# The main audio receiving/sending loop, this loops
# until AudioSocket stops sending us data, the hangup() method is called or an error occurs.
# A disconnection can be triggered from the users end by calling the hangup() method
while True:
data = None
try:
with self._lock:
data = self.conn.recv(323)
except ConnectionResetError:
pass
if not data:
self.connected = False
self.conn.close()
return
type, length, payload = self._split_data(data)
if type == types.audio:
# Adds received audio into the rx queue
if self._rx_q.full():
print(
"[AUDIOSOCKET WARNING] The inbound audio queue is full! This most "
+ "likely occurred because the read() method is not being called, skipping frame"
)
else:
self._rx_q.put(payload)
# To prevent the tx queue from blocking all execution if
# the user doesn't supply it with (enough) audio, silence is
# generated manually and sent back to AudioSocket whenever its empty.
if self._tx_q.empty():
self.conn.send(types.audio + PCM_SIZE + bytes(320))
else:
# If a single peice of audio data in the rx queue is larger than
# 320 bytes, slice it before sending, however...
# If the audio data to send is larger than this, then
# it's probably in the wrong format to begin with and wont be
# played back properly even when sliced.
audio_data = self._tx_q.get()[:320]
with self._lock:
self.conn.send(
types.audio
+ len(audio_data).to_bytes(2, "big")
+ audio_data
)
elif type == types.error:
self._decode_error(payload)
elif type == types.uuid:
self.uuid = payload.hex()

View file

@ -0,0 +1,98 @@
import os
import sys
import urllib3
from multiprocessing import Pipe, Process
from threading import Thread
from .audiosocket import Audiosocket
def open_url(direction, connection, username, password):
print(f"start {direction}", flush=True)
if direction not in ("transmit", "receive"):
raise NotImplementedError
method, headers = {
"receive": ("GET", {}),
"transmit": ("POST", {"Content-Type": "audio/basic", "Content-Length": "0"}),
}[direction]
url = f"http://192.168.0.74/axis-cgi/audio/{direction}.cgi"
http = urllib3.PoolManager()
print(f"start {direction} request", flush=True)
response = http.request(
method,
url,
headers={
**urllib3.make_headers(basic_auth=f"{username}:{password}"),
**headers,
},
preload_content=False,
body=(
None
if direction != "transmit"
else os.fdopen(connection.fileno(), "rb", buffering=0)
),
)
print(f"{direction} status is {response.status}", flush=True)
if direction == "receive":
for data in response.stream(amt=160):
connection.send_bytes(data)
def handle_connection(call, username, password):
print(f"Received connection from {call.peer_addr}")
pipe_transmit_in, pipe_transmit_out = Pipe()
doorbell_transmit_process = Process(
target=open_url, args=("transmit", pipe_transmit_out, username, password)
)
doorbell_transmit_process.start()
pipe_receive_in, pipe_receive_out = Pipe()
doorbell_receive_process = Process(
target=open_url, args=("receive", pipe_receive_in, username, password)
)
doorbell_receive_process.start()
with os.fdopen(os.dup(pipe_transmit_in.fileno()), "wb", buffering=0) as f_transmit:
while call.connected:
f_transmit.write(call.read())
call.write(pipe_receive_out.recv_bytes())
print(f"Connection with {call.peer_addr} is now over")
doorbell_transmit_process.terminate()
doorbell_receive_process.terminate()
doorbell_transmit_process.join()
doorbell_receive_process.join()
def main():
audiosocket = Audiosocket(
(os.environ["LISTEN_ADDRESS"], int(os.environ["LISTEN_PORT"]))
)
audiosocket.prepare_output(outrate=8000, channels=1, lin2ulaw=True)
audiosocket.prepare_input(inrate=8000, channels=1, ulaw2lin=True)
print("Listening for new connections " f"from Asterisk on port {audiosocket.port}")
username = os.environ["USERNAME"]
with open(os.environ["PASSWORD_FILE"], "r", encoding="utf-8") as f:
password = f.read()
while True:
call = audiosocket.listen()
call_thread = Thread(target=handle_connection, args=(call, username, password))
call_thread.start()
call_thread.join()
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,36 @@
import json
import os
import sys
import tempfile
import urllib3
def open_door(host, username, password):
urllib3.PoolManager().request(
"GET",
f"http://{host}/local/Doorcom/door.cgi?r=1",
headers=urllib3.make_headers(basic_auth=f"{username}:{password}"),
)
def read_file_contents(file_name):
with open(file_name, "r", encoding="utf-8") as f:
return f.read()
def main():
with open(
"/etc/myintercom-doorbell/settings.json", "r", encoding="utf-8"
) as config_file:
config = json.load(config_file)
open_door(
host=config["host"],
username=config["username"],
password=read_file_contents(config["passwordFile"]),
)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,98 @@
import json
import os
import tempfile
import urllib3
import time
def send_open_door_request(host, username, password):
urllib3.PoolManager().request(
"GET",
f"http://{host}/local/Doorcom/door.cgi?r=1",
headers=urllib3.make_headers(basic_auth=f"{username}:{password}"),
)
def get_ring_status(host, username, password):
response = urllib3.PoolManager().request(
"GET",
f"http://{host}/local/Doorcom/monitor.cgi?ring=1",
headers=urllib3.make_headers(basic_auth=f"{username}:{password}"),
preload_content=False,
decode_content=True,
)
while True:
line = response.readline()
if line != b"--ioboundary\r\n":
continue
header = response.readline()
if header != b"Content-Type: text/plain\r\n":
continue
if response.readline() != b"\r\n":
continue
data = []
while True:
line = response.readline()
if line != b"\r\n":
data.append(line.decode().rstrip())
else:
if data:
yield data
break
def read_file_contents(file_name):
with open(file_name, "r", encoding="utf-8") as f:
return f.read()
def open_door():
with open(
"/etc/myintercom-doorbell/settings.json", "r", encoding="utf-8"
) as config_file:
config = json.load(config_file)
send_open_door_request(
host=config["host"],
username=config["username"],
password=read_file_contents(config["passwordFile"]),
)
def poll():
outgoing_dir = "/var/spool/asterisk/outgoing/"
with open(
"/etc/myintercom-doorbell/settings.json", "r", encoding="utf-8"
) as config_file:
config = json.load(config_file)
audiosocket = f"{config['audiosocket']['address']}:{config['audiosocket']['port']}/{config['audiosocket']['uuid']}"
callfile_content = (
f"Channel: Audiosocket/{audiosocket}\n"
"Context: doorbell\n"
f"CallerID: {config['callerId']}\n"
"Extension: s\n"
"Priority: 1\n"
)
while True:
for status in get_ring_status(
host=config["host"],
username=config["username"],
password=read_file_contents(config["passwordFile"]),
):
if status == ["1:H"]:
print("ringing", flush=True)
with tempfile.NamedTemporaryFile(dir="/var/tmp", mode="w") as f:
f.write(callfile_content)
os.chmod(f.name, 0o644)
os.link(
f.name, os.path.join(outgoing_dir, os.path.basename(f.name))
)
time.sleep(config["dialTime"])
else:
print(".", end="", flush=True)

24
pkgs/myintercom-doorbell/poetry.lock generated Normal file
View file

@ -0,0 +1,24 @@
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "urllib3"
version = "2.0.7"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"},
{file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "cb8f26af67979881c24e243af0719b405141855256ced72c73b222b6c03d2bb8"

View file

@ -0,0 +1,20 @@
[tool.poetry]
name = "myintercom-doorbell"
version = "0.1.0"
description = ""
authors = ["Jakob Lechner <mail@jalr.de>"]
readme = "README.md"
packages = [{include = "myintercom_doorbell"}]
[tool.poetry.dependencies]
python = "^3.10"
urllib3 = "^2.0.7"
[tool.poetry.scripts]
myintercom-doorbell-audiosocket = "myintercom_doorbell.myintercom_audiosocket:main"
myintercom-doorbell-open-door = "myintercom_doorbell.service:open_door"
myintercom-doorbell-poll = "myintercom_doorbell.service:poll"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,8 @@
with import <nixpkgs> { };
mkShell {
buildInputs = [
poetry
];
}