Add myintercom doorbell
This commit is contained in:
parent
4aa948f8d4
commit
aba0d00afd
19 changed files with 764 additions and 2 deletions
|
|
@ -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: |-
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
imports = [
|
||||
./asterisk.nix
|
||||
./dnsmasq.nix
|
||||
./doorbell.nix
|
||||
./dyndns.nix
|
||||
./unifi-controller.nix
|
||||
];
|
||||
|
|
|
|||
19
hosts/aluminium/services/doorbell.nix
Normal file
19
hosts/aluminium/services/doorbell.nix
Normal 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";
|
||||
};
|
||||
}
|
||||
|
|
@ -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 { };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
{
|
||||
imports = [
|
||||
./myintercom-doorbell/module.nix
|
||||
./pretix/module.nix
|
||||
];
|
||||
}
|
||||
|
|
|
|||
1
pkgs/myintercom-doorbell/.envrc
Normal file
1
pkgs/myintercom-doorbell/.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use nix
|
||||
1
pkgs/myintercom-doorbell/.gitignore
vendored
Normal file
1
pkgs/myintercom-doorbell/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/dist
|
||||
0
pkgs/myintercom-doorbell/README.md
Normal file
0
pkgs/myintercom-doorbell/README.md
Normal file
12
pkgs/myintercom-doorbell/default.nix
Normal file
12
pkgs/myintercom-doorbell/default.nix
Normal 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;
|
||||
};
|
||||
});
|
||||
}
|
||||
99
pkgs/myintercom-doorbell/module.nix
Normal file
99
pkgs/myintercom-doorbell/module.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
83
pkgs/myintercom-doorbell/myintercom_doorbell/audiosocket.py
Normal file
83
pkgs/myintercom-doorbell/myintercom_doorbell/audiosocket.py
Normal 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?
|
||||
245
pkgs/myintercom-doorbell/myintercom_doorbell/connection.py
Normal file
245
pkgs/myintercom-doorbell/myintercom_doorbell/connection.py
Normal 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()
|
||||
|
|
@ -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())
|
||||
36
pkgs/myintercom-doorbell/myintercom_doorbell/open.py
Normal file
36
pkgs/myintercom-doorbell/myintercom_doorbell/open.py
Normal 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())
|
||||
98
pkgs/myintercom-doorbell/myintercom_doorbell/service.py
Normal file
98
pkgs/myintercom-doorbell/myintercom_doorbell/service.py
Normal 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
24
pkgs/myintercom-doorbell/poetry.lock
generated
Normal 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"
|
||||
20
pkgs/myintercom-doorbell/pyproject.toml
Normal file
20
pkgs/myintercom-doorbell/pyproject.toml
Normal 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"
|
||||
8
pkgs/myintercom-doorbell/shell.nix
Normal file
8
pkgs/myintercom-doorbell/shell.nix
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
with import <nixpkgs> { };
|
||||
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
poetry
|
||||
];
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue