diff --git a/hosts/aluminium/secrets.yaml b/hosts/aluminium/secrets.yaml index 6de2513..a920ae9 100644 --- a/hosts/aluminium/secrets.yaml +++ b/hosts/aluminium/secrets.yaml @@ -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: |- diff --git a/hosts/aluminium/services/asterisk.nix b/hosts/aluminium/services/asterisk.nix index 9ea5b1f..82e00ea 100644 --- a/hosts/aluminium/services/asterisk.nix +++ b/hosts/aluminium/services/asterisk.nix @@ -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 diff --git a/hosts/aluminium/services/default.nix b/hosts/aluminium/services/default.nix index 1ab0528..bb55c33 100644 --- a/hosts/aluminium/services/default.nix +++ b/hosts/aluminium/services/default.nix @@ -2,6 +2,7 @@ imports = [ ./asterisk.nix ./dnsmasq.nix + ./doorbell.nix ./dyndns.nix ./unifi-controller.nix ]; diff --git a/hosts/aluminium/services/doorbell.nix b/hosts/aluminium/services/doorbell.nix new file mode 100644 index 0000000..8136132 --- /dev/null +++ b/hosts/aluminium/services/doorbell.nix @@ -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"; + }; +} diff --git a/pkgs/default.nix b/pkgs/default.nix index f3fd495..1d0a068 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -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 { }; diff --git a/pkgs/modules.nix b/pkgs/modules.nix index 02b906b..3e1a8e3 100644 --- a/pkgs/modules.nix +++ b/pkgs/modules.nix @@ -2,6 +2,7 @@ { imports = [ + ./myintercom-doorbell/module.nix ./pretix/module.nix ]; } diff --git a/pkgs/myintercom-doorbell/.envrc b/pkgs/myintercom-doorbell/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/pkgs/myintercom-doorbell/.envrc @@ -0,0 +1 @@ +use nix diff --git a/pkgs/myintercom-doorbell/.gitignore b/pkgs/myintercom-doorbell/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/pkgs/myintercom-doorbell/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/pkgs/myintercom-doorbell/README.md b/pkgs/myintercom-doorbell/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pkgs/myintercom-doorbell/default.nix b/pkgs/myintercom-doorbell/default.nix new file mode 100644 index 0000000..9d7fd58 --- /dev/null +++ b/pkgs/myintercom-doorbell/default.nix @@ -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; + }; + }); +} diff --git a/pkgs/myintercom-doorbell/module.nix b/pkgs/myintercom-doorbell/module.nix new file mode 100644 index 0000000..2acc1fe --- /dev/null +++ b/pkgs/myintercom-doorbell/module.nix @@ -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"; + }; + }; + }; +} diff --git a/pkgs/myintercom-doorbell/myintercom_doorbell/audiosocket.py b/pkgs/myintercom-doorbell/myintercom_doorbell/audiosocket.py new file mode 100644 index 0000000..0bb4453 --- /dev/null +++ b/pkgs/myintercom-doorbell/myintercom_doorbell/audiosocket.py @@ -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? diff --git a/pkgs/myintercom-doorbell/myintercom_doorbell/connection.py b/pkgs/myintercom-doorbell/myintercom_doorbell/connection.py new file mode 100644 index 0000000..12767ea --- /dev/null +++ b/pkgs/myintercom-doorbell/myintercom_doorbell/connection.py @@ -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() diff --git a/pkgs/myintercom-doorbell/myintercom_doorbell/myintercom_audiosocket.py b/pkgs/myintercom-doorbell/myintercom_doorbell/myintercom_audiosocket.py new file mode 100644 index 0000000..6467d24 --- /dev/null +++ b/pkgs/myintercom-doorbell/myintercom_doorbell/myintercom_audiosocket.py @@ -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()) diff --git a/pkgs/myintercom-doorbell/myintercom_doorbell/open.py b/pkgs/myintercom-doorbell/myintercom_doorbell/open.py new file mode 100644 index 0000000..f3a7211 --- /dev/null +++ b/pkgs/myintercom-doorbell/myintercom_doorbell/open.py @@ -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()) diff --git a/pkgs/myintercom-doorbell/myintercom_doorbell/service.py b/pkgs/myintercom-doorbell/myintercom_doorbell/service.py new file mode 100644 index 0000000..d7abd14 --- /dev/null +++ b/pkgs/myintercom-doorbell/myintercom_doorbell/service.py @@ -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) diff --git a/pkgs/myintercom-doorbell/poetry.lock b/pkgs/myintercom-doorbell/poetry.lock new file mode 100644 index 0000000..818c966 --- /dev/null +++ b/pkgs/myintercom-doorbell/poetry.lock @@ -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" diff --git a/pkgs/myintercom-doorbell/pyproject.toml b/pkgs/myintercom-doorbell/pyproject.toml new file mode 100644 index 0000000..6a7878f --- /dev/null +++ b/pkgs/myintercom-doorbell/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "myintercom-doorbell" +version = "0.1.0" +description = "" +authors = ["Jakob Lechner "] +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" diff --git a/pkgs/myintercom-doorbell/shell.nix b/pkgs/myintercom-doorbell/shell.nix new file mode 100644 index 0000000..cd52c53 --- /dev/null +++ b/pkgs/myintercom-doorbell/shell.nix @@ -0,0 +1,8 @@ +with import { }; + +mkShell { + buildInputs = [ + poetry + ]; + +}