refactor: restructure voip module
This commit is contained in:
parent
de236e371e
commit
d2b4eb483f
16 changed files with 965 additions and 924 deletions
137
modules/voip/assertions.nix
Normal file
137
modules/voip/assertions.nix
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
{ lib, cfg, models, allPhones }:
|
||||||
|
|
||||||
|
# Provisioned sharedPhones require a MAC address key
|
||||||
|
(lib.mapAttrsToList (key: phone: {
|
||||||
|
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
||||||
|
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||||
|
}) cfg.sharedPhones)
|
||||||
|
++
|
||||||
|
# Provisioned sharedPhones require a non-empty label
|
||||||
|
(lib.mapAttrsToList (key: phone: {
|
||||||
|
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
||||||
|
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||||
|
}) cfg.sharedPhones)
|
||||||
|
++
|
||||||
|
# Provisioned person phones require a MAC address key
|
||||||
|
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
||||||
|
lib.mapAttrsToList (key: phone: {
|
||||||
|
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
||||||
|
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||||
|
}) person.phones
|
||||||
|
) cfg.persons))
|
||||||
|
++
|
||||||
|
# Provisioned person phones require a non-empty label
|
||||||
|
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
||||||
|
lib.mapAttrsToList (key: phone: {
|
||||||
|
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
||||||
|
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||||
|
}) person.phones
|
||||||
|
) cfg.persons))
|
||||||
|
++
|
||||||
|
# No duplicate phone keys across sharedPhones and persons.*.phones
|
||||||
|
[{
|
||||||
|
assertion =
|
||||||
|
let keys = lib.attrNames allPhones;
|
||||||
|
in lib.length keys == lib.length (lib.unique keys);
|
||||||
|
message = "services.voip: duplicate phone key detected across sharedPhones and persons.*.phones";
|
||||||
|
}]
|
||||||
|
++
|
||||||
|
# No duplicate extensions across sharedPhones and persons
|
||||||
|
[{
|
||||||
|
assertion =
|
||||||
|
let
|
||||||
|
exts = (lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones)
|
||||||
|
++ (lib.mapAttrsToList (_: p: p.extension) cfg.persons);
|
||||||
|
in lib.length exts == lib.length (lib.unique exts);
|
||||||
|
message = "services.voip: duplicate extension number across sharedPhones and persons";
|
||||||
|
}]
|
||||||
|
++
|
||||||
|
# dids require at least one sipTrunk
|
||||||
|
(lib.optionals (cfg.dids != {}) [{
|
||||||
|
assertion = cfg.sipTrunks != {};
|
||||||
|
message = "services.voip: dids are configured but sipTrunks is empty";
|
||||||
|
}])
|
||||||
|
++
|
||||||
|
# each DID must reference an existing trunk
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = lib.hasAttr didCfg.trunk cfg.sipTrunks;
|
||||||
|
message = "services.voip: DID ${did} references trunk \"${didCfg.trunk}\" which is not in services.voip.sipTrunks";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids with mailbox="shared" require sharedMailbox
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = didCfg.mailbox != "shared" || cfg.sharedMailbox != null;
|
||||||
|
message = "services.voip: DID ${did} has mailbox=\"shared\" but sharedMailbox is not configured";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids with mailbox="person" require routing.type="person"
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = didCfg.mailbox != "person" || didCfg.routing.type == "person";
|
||||||
|
message = "services.voip: DID ${did} has mailbox=\"person\" but routing.type is not \"person\"";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids routing.type="person" — person key must be non-empty
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = didCfg.routing.type != "person" || didCfg.routing.person != "";
|
||||||
|
message = "services.voip: DID ${did} has routing.type=\"person\" but routing.person is not set";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids routing.type="person" — referenced person must exist
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = didCfg.routing.type != "person" || didCfg.routing.person == "" || lib.hasAttr didCfg.routing.person cfg.persons;
|
||||||
|
message = "services.voip: DID ${did} references person \"${didCfg.routing.person}\" which is not in services.voip.persons";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids routing.type="persons" — persons list must be non-empty
|
||||||
|
(lib.mapAttrsToList (did: didCfg: {
|
||||||
|
assertion = didCfg.routing.type != "persons" || didCfg.routing.persons != [];
|
||||||
|
message = "services.voip: DID ${did} has routing.type=\"persons\" but routing.persons is empty";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# dids routing.type="persons" — all referenced persons must exist
|
||||||
|
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
||||||
|
lib.optionals (didCfg.routing.type == "persons")
|
||||||
|
(map (p: {
|
||||||
|
assertion = lib.hasAttr p cfg.persons;
|
||||||
|
message = "services.voip: DID ${did} references person \"${p}\" which is not in services.voip.persons";
|
||||||
|
}) didCfg.routing.persons)
|
||||||
|
) cfg.dids))
|
||||||
|
++
|
||||||
|
# dids musicOnHold must reference an existing mohClass
|
||||||
|
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
||||||
|
lib.optional (didCfg.musicOnHold != null) {
|
||||||
|
assertion = lib.hasAttr didCfg.musicOnHold cfg.mohClasses;
|
||||||
|
message = "services.voip: DID ${did} references mohClass \"${didCfg.musicOnHold}\" which is not in services.voip.mohClasses";
|
||||||
|
}
|
||||||
|
) cfg.dids))
|
||||||
|
++
|
||||||
|
# sipTrunks: each required field needs either a literal or a file
|
||||||
|
(lib.concatLists (lib.mapAttrsToList (name: t: [
|
||||||
|
{ assertion = t.host != "" || t.hostFile != null;
|
||||||
|
message = "services.voip: sipTrunks.\"${name}\" requires host or hostFile"; }
|
||||||
|
{ assertion = t.username != "" || t.usernameFile != null;
|
||||||
|
message = "services.voip: sipTrunks.\"${name}\" requires username or usernameFile"; }
|
||||||
|
{ assertion = t.password != "" || t.passwordFile != null;
|
||||||
|
message = "services.voip: sipTrunks.\"${name}\" requires password or passwordFile"; }
|
||||||
|
]) cfg.sipTrunks))
|
||||||
|
++
|
||||||
|
# dids: each DID needs a number either inline or from a file
|
||||||
|
(lib.mapAttrsToList (id: d: {
|
||||||
|
assertion = d.number != "" || d.numberFile != null;
|
||||||
|
message = "services.voip: dids.\"${id}\" requires number or numberFile";
|
||||||
|
}) cfg.dids)
|
||||||
|
++
|
||||||
|
# extensions with mode="app" must have a non-null app field
|
||||||
|
(lib.mapAttrsToList (ext: extCfg: {
|
||||||
|
assertion = extCfg.mode != "app" || extCfg.app != null;
|
||||||
|
message = "services.voip: extension \"${ext}\" has mode=\"app\" but app is not set";
|
||||||
|
}) cfg.extensions)
|
||||||
|
++
|
||||||
|
# intercomPrefix must not collide with any declared extension
|
||||||
|
(lib.optionals (cfg.intercomPrefix != null)
|
||||||
|
(lib.mapAttrsToList (_key: phone:
|
||||||
|
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
|
||||||
|
assertion = !lib.hasAttr ext cfg.extensions;
|
||||||
|
message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix";
|
||||||
|
}
|
||||||
|
) allPhones))
|
||||||
|
|
@ -42,7 +42,7 @@ let
|
||||||
# Shared phones (no personal mailbox) and sip-clients use L1 for everything.
|
# Shared phones (no personal mailbox) and sip-clients use L1 for everything.
|
||||||
phoneHasL2 = key:
|
phoneHasL2 = key:
|
||||||
let phone = allPhones.${key};
|
let phone = allPhones.${key};
|
||||||
in phone.personKey != null && models.${phone.model}.hasProvisioning;
|
in phone.personKey != null && models.${phone.model}.hasMultiLine;
|
||||||
|
|
||||||
# Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once)
|
# Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once)
|
||||||
dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}";
|
dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}";
|
||||||
|
|
@ -93,12 +93,11 @@ let
|
||||||
+ lib.optionalString (t.callerId != "" || t.callerIdFile != null)
|
+ lib.optionalString (t.callerId != "" || t.callerIdFile != null)
|
||||||
(runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n ");
|
(runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n ");
|
||||||
|
|
||||||
# Page: only provisioned phones with an intercom line (auto-answer speakerphone).
|
# Page: only intercom-capable phones with an intercom line (auto-answer speakerphone).
|
||||||
# sip-clients have no dedicated intercom endpoint and are excluded.
|
|
||||||
allPageEndpoints = lib.concatStringsSep "&"
|
allPageEndpoints = lib.concatStringsSep "&"
|
||||||
(lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom")
|
(lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom")
|
||||||
(lib.filterAttrs (key: phone:
|
(lib.filterAttrs (key: phone:
|
||||||
models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null
|
models.${phone.model}.hasIntercom && cfg.intercomPrefix != null
|
||||||
) allPhones));
|
) allPhones));
|
||||||
|
|
||||||
# --- PJSIP endpoint generators ---
|
# --- PJSIP endpoint generators ---
|
||||||
|
|
@ -20,7 +20,7 @@ let
|
||||||
# Whether a phone gets an L2 line (same logic as asterisk.nix)
|
# Whether a phone gets an L2 line (same logic as asterisk.nix)
|
||||||
phoneHasL2 = key:
|
phoneHasL2 = key:
|
||||||
let phone = allPhones.${key};
|
let phone = allPhones.${key};
|
||||||
in phone.personKey != null && models.${phone.model}.hasProvisioning;
|
in phone.personKey != null && models.${phone.model}.hasMultiLine;
|
||||||
|
|
||||||
# ── Nodes ───────────────────────────────────────────────────────────────
|
# ── Nodes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -126,9 +126,9 @@ let
|
||||||
'' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]''
|
'' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]''
|
||||||
) intercomEntries;
|
) intercomEntries;
|
||||||
|
|
||||||
# Page extension → all phones
|
# Page extension → intercom-capable phones
|
||||||
pagePhones = lib.filterAttrs (key: phone:
|
pagePhones = lib.filterAttrs (key: phone:
|
||||||
models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null
|
models.${phone.model}.hasIntercom && cfg.intercomPrefix != null
|
||||||
) allPhones;
|
) allPhones;
|
||||||
|
|
||||||
pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg:
|
pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg:
|
||||||
|
|
@ -1,66 +1,21 @@
|
||||||
{ lib, pkgs, config, ... }:
|
{ lib, pkgs, config, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.services.voip;
|
cfg = config.services.voip;
|
||||||
|
phones = import ./phones.nix { inherit lib; };
|
||||||
|
|
||||||
# Per-model config. Adding a new hardware model:
|
inherit (phones) models;
|
||||||
# 1. Add an entry here with all required fields
|
allPhones = phones.mkAllPhones cfg;
|
||||||
# 2. For provisioned models, add a template in ./templates/<model>.nix
|
intercomEntries = phones.mkIntercomEntries cfg allPhones;
|
||||||
#
|
|
||||||
# Template interface — all provisioned model templates receive these args:
|
|
||||||
# Required: mac, label, displayName, password, serverAddress, ntpServer
|
|
||||||
# Optional: sipPort (default 5060), directoryPort (default 8080),
|
|
||||||
# intercomEnabled (default false), intercomPassword (default ""),
|
|
||||||
# familyLineEnabled (default false), familyLineLabel (default "Familie")
|
|
||||||
models = {
|
|
||||||
"cisco-8961" = {
|
|
||||||
endpointTemplate = "endpoint-cisco-8961";
|
|
||||||
maxContacts = 1;
|
|
||||||
hasProvisioning = true;
|
|
||||||
desktopSize = "640x480x24";
|
|
||||||
thumbnailSize = "123x111";
|
|
||||||
template = import ./templates/cisco-8961.nix;
|
|
||||||
};
|
|
||||||
"sip-client" = {
|
|
||||||
endpointTemplate = "endpoint-generic";
|
|
||||||
maxContacts = 1;
|
|
||||||
hasProvisioning = false;
|
|
||||||
desktopSize = null;
|
|
||||||
thumbnailSize = null;
|
|
||||||
template = null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Unified view of all physical devices, keyed by SIP identity (MAC or username).
|
mohDirs = import ./asterisk/moh.nix { inherit lib pkgs cfg; };
|
||||||
# Each entry carries the fields needed by sub-modules without them having to
|
greetingDirs = import ./asterisk/greetings.nix { inherit lib pkgs cfg; };
|
||||||
# know about sharedPhones vs persons.
|
confFiles = import ./asterisk/default.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; };
|
||||||
allPhones =
|
|
||||||
lib.mapAttrs (key: p: {
|
|
||||||
inherit (p) model label password;
|
|
||||||
extension = p.extension;
|
|
||||||
displayName = p.displayName;
|
|
||||||
personKey = null;
|
|
||||||
mailboxExt = null; # shared phones have no personal mailbox
|
|
||||||
}) cfg.sharedPhones
|
|
||||||
//
|
|
||||||
lib.foldlAttrs (acc: personKey: person:
|
|
||||||
acc // lib.mapAttrs (_key: ph: {
|
|
||||||
inherit (ph) model label password;
|
|
||||||
extension = person.extension;
|
|
||||||
displayName = person.displayName;
|
|
||||||
personKey = personKey;
|
|
||||||
mailboxExt = if person.mailbox then person.extension else null;
|
|
||||||
}) person.phones
|
|
||||||
) {} cfg.persons;
|
|
||||||
|
|
||||||
mohDirs = import ./moh.nix { inherit lib pkgs cfg; };
|
directory = import ./provisioning/directory.nix { inherit lib pkgs cfg allPhones intercomEntries; };
|
||||||
greetingDirs = import ./greetings.nix { inherit lib pkgs cfg; };
|
provisioningRoot = import ./provisioning/default.nix { inherit lib pkgs cfg models allPhones; };
|
||||||
intercomEntries = import ./intercom.nix { inherit lib cfg models allPhones; };
|
|
||||||
confFiles = import ./asterisk.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; };
|
diagram = import ./dashboard.nix { inherit lib pkgs cfg models allPhones intercomEntries; };
|
||||||
directory = import ./directory.nix { inherit lib pkgs cfg allPhones intercomEntries; };
|
|
||||||
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models allPhones; };
|
|
||||||
provisioningRoot = import ./provisioning.nix { inherit lib pkgs cfg models allPhones backgroundEntries; };
|
|
||||||
diagram = import ./diagram.nix { inherit lib pkgs cfg models allPhones intercomEntries; };
|
|
||||||
|
|
||||||
# True when any *File option is set — Asterisk's execincludes=yes is required in that case.
|
# True when any *File option is set — Asterisk's execincludes=yes is required in that case.
|
||||||
hasRuntimeSecrets =
|
hasRuntimeSecrets =
|
||||||
|
|
@ -85,520 +40,15 @@ let
|
||||||
ngx.header.content_type = "text/html; charset=utf-8"
|
ngx.header.content_type = "text/html; charset=utf-8"
|
||||||
ngx.print(html)
|
ngx.print(html)
|
||||||
'';
|
'';
|
||||||
# Shared option set for a physical phone device.
|
|
||||||
# isPersonPhone = true → no extension/displayName fields (inherited from person)
|
|
||||||
# isPersonPhone = false → includes extension and displayName
|
|
||||||
phoneDeviceOptions = isPersonPhone: {
|
|
||||||
model = lib.mkOption {
|
|
||||||
type = lib.types.enum (lib.attrNames models);
|
|
||||||
description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file).";
|
|
||||||
};
|
|
||||||
label = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Label shown on the phone screen (max ~12 chars for Cisco). Required for provisioned hardware phones.";
|
|
||||||
};
|
|
||||||
password = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "SIP registration password.";
|
|
||||||
};
|
|
||||||
} // lib.optionalAttrs (!isPersonPhone) {
|
|
||||||
extension = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "Internal extension number for this shared phone.";
|
|
||||||
};
|
|
||||||
displayName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Name shown in the phone directory.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
in {
|
in {
|
||||||
options.services.voip = {
|
imports = [ ./options.nix ];
|
||||||
enable = lib.mkEnableOption "VoIP provisioning (Asterisk + TFTP)";
|
|
||||||
|
|
||||||
serverAddress = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "IP address or hostname of this server (used in phone configs and SIP).";
|
|
||||||
};
|
|
||||||
|
|
||||||
ntpServer = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "NTP server for phones. Defaults to serverAddress.";
|
|
||||||
default = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
sipPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 5060;
|
|
||||||
};
|
|
||||||
|
|
||||||
rtpStart = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 10000;
|
|
||||||
};
|
|
||||||
|
|
||||||
rtpEnd = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 20000;
|
|
||||||
};
|
|
||||||
|
|
||||||
directoryName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "tel.baubs.net";
|
|
||||||
description = "Name shown in the phone directory title.";
|
|
||||||
};
|
|
||||||
|
|
||||||
directoryPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 8080;
|
|
||||||
description = "HTTP port for the phone directory and services.";
|
|
||||||
};
|
|
||||||
|
|
||||||
backgroundImages = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Attrset of background images keyed by display name.
|
|
||||||
Value is a path to any image file — it will be resized automatically
|
|
||||||
to the correct dimensions for each phone model during build.
|
|
||||||
For best results, use a 4:3 aspect ratio source image.
|
|
||||||
'';
|
|
||||||
type = lib.types.attrsOf lib.types.path;
|
|
||||||
};
|
|
||||||
|
|
||||||
sharedPhones = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Shared/location phones not assigned to a specific person (e.g. hallway, kitchen).
|
|
||||||
These have their own extension but no personal voicemail mailbox.
|
|
||||||
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
|
||||||
For sip-client, the key is a free-form username.
|
|
||||||
'';
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = phoneDeviceOptions false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
persons = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = "People with personal extensions, optional voicemail mailboxes, and their own phones.";
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
extension = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "Personal extension number.";
|
|
||||||
};
|
|
||||||
displayName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Name shown in the directory and on caller ID.";
|
|
||||||
};
|
|
||||||
mailbox = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = "Whether this person gets a personal voicemail mailbox.";
|
|
||||||
};
|
|
||||||
ringTimeout = lib.mkOption {
|
|
||||||
type = lib.types.ints.positive;
|
|
||||||
default = 30;
|
|
||||||
description = "Seconds to ring before going to voicemail (or hanging up if no mailbox).";
|
|
||||||
};
|
|
||||||
mailboxGreeting = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
|
||||||
};
|
|
||||||
phones = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Phones belonging to this person, keyed by SIP identity.
|
|
||||||
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
|
||||||
For sip-client, the key is a free-form username.
|
|
||||||
'';
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = phoneDeviceOptions true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
intercomPrefix = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries.";
|
|
||||||
};
|
|
||||||
|
|
||||||
codecs = lib.mkOption {
|
|
||||||
description = "Codec preference lists for each endpoint class, ordered highest priority first.";
|
|
||||||
default = {};
|
|
||||||
type = lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
hardwarePhones = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "g722" "alaw" "ulaw" "ilbc" ];
|
|
||||||
description = "Codecs for provisioned hardware phones (e.g. Cisco 8961). Supports G.722, G.711, iLBC. Opus and G.726 not supported. G.729 supported but not useful on LAN.";
|
|
||||||
};
|
|
||||||
softClients = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "opus" "g722" "alaw" "ulaw" ];
|
|
||||||
description = "Codecs for software SIP clients. Opus first for best quality on modern softphones.";
|
|
||||||
};
|
|
||||||
trunk = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "alaw" "ulaw" ];
|
|
||||||
description = "Codecs offered to SIP trunks. Most providers only support G.711.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mohClasses = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = "Music on hold classes. Files are transcoded to ulaw at build time.";
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
files = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.path;
|
|
||||||
description = "Audio files to play (MP3, WAV, etc). Transcoded to 8kHz ulaw automatically.";
|
|
||||||
};
|
|
||||||
sort = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "random" "alphabetical" ];
|
|
||||||
default = "random";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
sipTrunks = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = "External SIP provider trunks, keyed by a short name (e.g. \"provider-a\").";
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
host = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "SIP provider hostname or IP address. Use hostFile to read from a file.";
|
|
||||||
};
|
|
||||||
hostFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "File containing the SIP provider hostname. Takes precedence over host.";
|
|
||||||
};
|
|
||||||
username = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "SIP account username. Use usernameFile to read from a file.";
|
|
||||||
};
|
|
||||||
usernameFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "File containing the SIP account username. Takes precedence over username.";
|
|
||||||
};
|
|
||||||
password = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "SIP account password. Use passwordFile to read from a file.";
|
|
||||||
};
|
|
||||||
passwordFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "File containing the SIP account password. Takes precedence over password.";
|
|
||||||
};
|
|
||||||
transport = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "udp" "tcp" ];
|
|
||||||
default = "udp";
|
|
||||||
};
|
|
||||||
callerId = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Outbound caller ID number presented to the provider. Leave empty to use provider default. Use callerIdFile to read from a file.";
|
|
||||||
};
|
|
||||||
callerIdFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "File containing the outbound caller ID. Takes precedence over callerId.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
sharedMailbox = lib.mkOption {
|
|
||||||
default = null;
|
|
||||||
description = "Shared voicemail mailbox accessible by all phones (family answering machine).";
|
|
||||||
type = lib.types.nullOr (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
mailboxId = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "200";
|
|
||||||
description = "Numeric mailbox ID used internally in voicemail.conf and VoiceMail(). Must be numeric.";
|
|
||||||
};
|
|
||||||
checkExtension = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "*98";
|
|
||||||
description = "Extension dialled to check this shared mailbox directly (via VoiceMailMain).";
|
|
||||||
};
|
|
||||||
displayName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "Shared";
|
|
||||||
description = "Name shown in voicemail configuration.";
|
|
||||||
};
|
|
||||||
greeting = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
dids = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = "Inbound DID routing. Each DID must reference a key from sipTrunks.";
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
number = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "DID number in E.164 format (e.g. \"+4912345678\"). Use numberFile to read from a file.";
|
|
||||||
};
|
|
||||||
numberFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
|
||||||
description = "File containing the DID number. Takes precedence over number.";
|
|
||||||
};
|
|
||||||
trunk = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "Key of the sipTrunks entry this DID arrives on.";
|
|
||||||
};
|
|
||||||
displayName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Human-readable label for this DID (informational only).";
|
|
||||||
};
|
|
||||||
routing = lib.mkOption {
|
|
||||||
description = "How inbound calls on this DID are distributed to phones.";
|
|
||||||
type = lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
type = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "all" "person" "persons" ];
|
|
||||||
description = ''
|
|
||||||
all — ring all phones (sharedPhones + all persons) on their L2 line
|
|
||||||
person — ring a single person on their L1 line
|
|
||||||
persons — ring a list of persons on their L2 line
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
person = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = "Person key for routing.type = \"person\".";
|
|
||||||
};
|
|
||||||
persons = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [];
|
|
||||||
description = "Person keys for routing.type = \"persons\".";
|
|
||||||
};
|
|
||||||
timeout = lib.mkOption {
|
|
||||||
type = lib.types.ints.positive;
|
|
||||||
default = 30;
|
|
||||||
description = "Seconds to ring before going to voicemail (or hanging up).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
mailbox = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "shared" "person" "none" ];
|
|
||||||
default = "shared";
|
|
||||||
description = ''
|
|
||||||
shared — go to sharedMailbox on no answer (requires sharedMailbox to be set)
|
|
||||||
person — go to the routed person's mailbox on no answer (only valid with routing.type = "person")
|
|
||||||
none — hang up on no answer
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
musicOnHold = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
MOH class name to play to the caller while phones ring, instead of ringback.
|
|
||||||
Must match a key in mohClasses. null = standard ringback.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
extensions = lib.mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Extra extensions: page groups and custom app entries.
|
|
||||||
Line extensions are auto-generated from sharedPhones and persons — do not declare them here.
|
|
||||||
'';
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
mode = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "page" "app" ];
|
|
||||||
default = "page";
|
|
||||||
description = ''
|
|
||||||
Extension mode:
|
|
||||||
- "page": one-way announcement to all phones
|
|
||||||
- "app": custom Asterisk dialplan application
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
displayName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
};
|
|
||||||
app = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = "Verbatim Asterisk dialplan app. Required for mode = \"app\".";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
||||||
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
|
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
|
||||||
|
|
||||||
assertions =
|
assertions = import ./assertions.nix { inherit lib cfg models allPhones; };
|
||||||
# Provisioned sharedPhones require a MAC address key
|
|
||||||
(lib.mapAttrsToList (key: phone: {
|
|
||||||
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
|
||||||
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
|
||||||
}) cfg.sharedPhones)
|
|
||||||
++
|
|
||||||
# Provisioned sharedPhones require a non-empty label
|
|
||||||
(lib.mapAttrsToList (key: phone: {
|
|
||||||
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
|
||||||
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
|
||||||
}) cfg.sharedPhones)
|
|
||||||
++
|
|
||||||
# Provisioned person phones require a MAC address key
|
|
||||||
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
|
||||||
lib.mapAttrsToList (key: phone: {
|
|
||||||
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
|
||||||
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
|
||||||
}) person.phones
|
|
||||||
) cfg.persons))
|
|
||||||
++
|
|
||||||
# Provisioned person phones require a non-empty label
|
|
||||||
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
|
||||||
lib.mapAttrsToList (key: phone: {
|
|
||||||
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
|
||||||
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a non-empty label";
|
|
||||||
}) person.phones
|
|
||||||
) cfg.persons))
|
|
||||||
++
|
|
||||||
# No duplicate phone keys across sharedPhones and persons.*.phones
|
|
||||||
[{
|
|
||||||
assertion =
|
|
||||||
let keys = lib.attrNames allPhones;
|
|
||||||
in lib.length keys == lib.length (lib.unique keys);
|
|
||||||
message = "services.voip: duplicate phone key detected across sharedPhones and persons.*.phones";
|
|
||||||
}]
|
|
||||||
++
|
|
||||||
# No duplicate extensions across sharedPhones and persons
|
|
||||||
[{
|
|
||||||
assertion =
|
|
||||||
let
|
|
||||||
exts = (lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones)
|
|
||||||
++ (lib.mapAttrsToList (_: p: p.extension) cfg.persons);
|
|
||||||
in lib.length exts == lib.length (lib.unique exts);
|
|
||||||
message = "services.voip: duplicate extension number across sharedPhones and persons";
|
|
||||||
}]
|
|
||||||
++
|
|
||||||
# dids require at least one sipTrunk
|
|
||||||
(lib.optionals (cfg.dids != {}) [{
|
|
||||||
assertion = cfg.sipTrunks != {};
|
|
||||||
message = "services.voip: dids are configured but sipTrunks is empty";
|
|
||||||
}])
|
|
||||||
++
|
|
||||||
# each DID must reference an existing trunk
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = lib.hasAttr didCfg.trunk cfg.sipTrunks;
|
|
||||||
message = "services.voip: DID ${did} references trunk \"${didCfg.trunk}\" which is not in services.voip.sipTrunks";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids with mailbox="shared" require sharedMailbox
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = didCfg.mailbox != "shared" || cfg.sharedMailbox != null;
|
|
||||||
message = "services.voip: DID ${did} has mailbox=\"shared\" but sharedMailbox is not configured";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids with mailbox="person" require routing.type="person"
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = didCfg.mailbox != "person" || didCfg.routing.type == "person";
|
|
||||||
message = "services.voip: DID ${did} has mailbox=\"person\" but routing.type is not \"person\"";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids routing.type="person" — person key must be non-empty
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = didCfg.routing.type != "person" || didCfg.routing.person != "";
|
|
||||||
message = "services.voip: DID ${did} has routing.type=\"person\" but routing.person is not set";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids routing.type="person" — referenced person must exist
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = didCfg.routing.type != "person" || didCfg.routing.person == "" || lib.hasAttr didCfg.routing.person cfg.persons;
|
|
||||||
message = "services.voip: DID ${did} references person \"${didCfg.routing.person}\" which is not in services.voip.persons";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids routing.type="persons" — persons list must be non-empty
|
|
||||||
(lib.mapAttrsToList (did: didCfg: {
|
|
||||||
assertion = didCfg.routing.type != "persons" || didCfg.routing.persons != [];
|
|
||||||
message = "services.voip: DID ${did} has routing.type=\"persons\" but routing.persons is empty";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# dids routing.type="persons" — all referenced persons must exist
|
|
||||||
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
|
||||||
lib.optionals (didCfg.routing.type == "persons")
|
|
||||||
(map (p: {
|
|
||||||
assertion = lib.hasAttr p cfg.persons;
|
|
||||||
message = "services.voip: DID ${did} references person \"${p}\" which is not in services.voip.persons";
|
|
||||||
}) didCfg.routing.persons)
|
|
||||||
) cfg.dids))
|
|
||||||
++
|
|
||||||
# dids musicOnHold must reference an existing mohClass
|
|
||||||
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
|
||||||
lib.optional (didCfg.musicOnHold != null) {
|
|
||||||
assertion = lib.hasAttr didCfg.musicOnHold cfg.mohClasses;
|
|
||||||
message = "services.voip: DID ${did} references mohClass \"${didCfg.musicOnHold}\" which is not in services.voip.mohClasses";
|
|
||||||
}
|
|
||||||
) cfg.dids))
|
|
||||||
++
|
|
||||||
# sipTrunks: each required field needs either a literal or a file
|
|
||||||
(lib.concatLists (lib.mapAttrsToList (name: t: [
|
|
||||||
{ assertion = t.host != "" || t.hostFile != null;
|
|
||||||
message = "services.voip: sipTrunks.\"${name}\" requires host or hostFile"; }
|
|
||||||
{ assertion = t.username != "" || t.usernameFile != null;
|
|
||||||
message = "services.voip: sipTrunks.\"${name}\" requires username or usernameFile"; }
|
|
||||||
{ assertion = t.password != "" || t.passwordFile != null;
|
|
||||||
message = "services.voip: sipTrunks.\"${name}\" requires password or passwordFile"; }
|
|
||||||
]) cfg.sipTrunks))
|
|
||||||
++
|
|
||||||
# dids: each DID needs a number either inline or from a file
|
|
||||||
(lib.mapAttrsToList (id: d: {
|
|
||||||
assertion = d.number != "" || d.numberFile != null;
|
|
||||||
message = "services.voip: dids.\"${id}\" requires number or numberFile";
|
|
||||||
}) cfg.dids)
|
|
||||||
++
|
|
||||||
# extensions with mode="app" must have a non-null app field
|
|
||||||
(lib.mapAttrsToList (ext: extCfg: {
|
|
||||||
assertion = extCfg.mode != "app" || extCfg.app != null;
|
|
||||||
message = "services.voip: extension \"${ext}\" has mode=\"app\" but app is not set";
|
|
||||||
}) cfg.extensions)
|
|
||||||
++
|
|
||||||
# intercomPrefix must not collide with any declared extension
|
|
||||||
(lib.optionals (cfg.intercomPrefix != null)
|
|
||||||
(lib.mapAttrsToList (_key: phone:
|
|
||||||
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
|
|
||||||
assertion = !lib.hasAttr ext cfg.extensions;
|
|
||||||
message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix";
|
|
||||||
}
|
|
||||||
) allPhones));
|
|
||||||
|
|
||||||
services.asterisk = {
|
services.asterisk = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{ lib, cfg, models, allPhones }:
|
|
||||||
|
|
||||||
if cfg.intercomPrefix == null then []
|
|
||||||
else lib.concatLists (lib.mapAttrsToList (key: phone:
|
|
||||||
let m = models.${phone.model}; in
|
|
||||||
lib.optional m.hasProvisioning {
|
|
||||||
extension = "${cfg.intercomPrefix}${phone.extension}";
|
|
||||||
endpoint = "${key}-intercom";
|
|
||||||
phoneKey = key;
|
|
||||||
target = phone.extension;
|
|
||||||
displayName = "Intercom ${phone.displayName}";
|
|
||||||
password = phone.password;
|
|
||||||
endpointTemplate = m.endpointTemplate;
|
|
||||||
maxContacts = m.maxContacts;
|
|
||||||
}
|
|
||||||
) allPhones)
|
|
||||||
349
modules/voip/options.nix
Normal file
349
modules/voip/options.nix
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
{ lib, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
phones = import ./phones.nix { inherit lib; };
|
||||||
|
in {
|
||||||
|
options.services.voip = {
|
||||||
|
enable = lib.mkEnableOption "VoIP provisioning (Asterisk + TFTP)";
|
||||||
|
|
||||||
|
serverAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "IP address or hostname of this server (used in phone configs and SIP).";
|
||||||
|
};
|
||||||
|
|
||||||
|
ntpServer = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "NTP server for phones. Defaults to serverAddress.";
|
||||||
|
default = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
sipPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 5060;
|
||||||
|
};
|
||||||
|
|
||||||
|
rtpStart = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 10000;
|
||||||
|
};
|
||||||
|
|
||||||
|
rtpEnd = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 20000;
|
||||||
|
};
|
||||||
|
|
||||||
|
directoryName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "tel.baubs.net";
|
||||||
|
description = "Name shown in the phone directory title.";
|
||||||
|
};
|
||||||
|
|
||||||
|
directoryPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8080;
|
||||||
|
description = "HTTP port for the phone directory and services.";
|
||||||
|
};
|
||||||
|
|
||||||
|
backgroundImages = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Attrset of background images keyed by display name.
|
||||||
|
Value is a path to any image file — it will be resized automatically
|
||||||
|
to the correct dimensions for each phone model during build.
|
||||||
|
For best results, use a 4:3 aspect ratio source image.
|
||||||
|
'';
|
||||||
|
type = lib.types.attrsOf lib.types.path;
|
||||||
|
};
|
||||||
|
|
||||||
|
sharedPhones = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Shared/location phones not assigned to a specific person (e.g. hallway, kitchen).
|
||||||
|
These have their own extension but no personal voicemail mailbox.
|
||||||
|
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
||||||
|
For sip-client, the key is a free-form username.
|
||||||
|
'';
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = phones.phoneDeviceOptions false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
persons = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = "People with personal extensions, optional voicemail mailboxes, and their own phones.";
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
extension = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Personal extension number.";
|
||||||
|
};
|
||||||
|
displayName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Name shown in the directory and on caller ID.";
|
||||||
|
};
|
||||||
|
mailbox = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether this person gets a personal voicemail mailbox.";
|
||||||
|
};
|
||||||
|
ringTimeout = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Seconds to ring before going to voicemail (or hanging up if no mailbox).";
|
||||||
|
};
|
||||||
|
mailboxGreeting = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
||||||
|
};
|
||||||
|
phones = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Phones belonging to this person, keyed by SIP identity.
|
||||||
|
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
||||||
|
For sip-client, the key is a free-form username.
|
||||||
|
'';
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = phones.phoneDeviceOptions true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
intercomPrefix = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries.";
|
||||||
|
};
|
||||||
|
|
||||||
|
codecs = lib.mkOption {
|
||||||
|
description = "Codec preference lists for each endpoint class, ordered highest priority first.";
|
||||||
|
default = {};
|
||||||
|
type = lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
hardwarePhones = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "g722" "alaw" "ulaw" "ilbc" ];
|
||||||
|
description = "Codecs for provisioned hardware phones (e.g. Cisco 8961). Supports G.722, G.711, iLBC. Opus and G.726 not supported. G.729 supported but not useful on LAN.";
|
||||||
|
};
|
||||||
|
softClients = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "opus" "g722" "alaw" "ulaw" ];
|
||||||
|
description = "Codecs for software SIP clients. Opus first for best quality on modern softphones.";
|
||||||
|
};
|
||||||
|
trunk = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "alaw" "ulaw" ];
|
||||||
|
description = "Codecs offered to SIP trunks. Most providers only support G.711.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
mohClasses = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = "Music on hold classes. Files are transcoded to ulaw at build time.";
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
files = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.path;
|
||||||
|
description = "Audio files to play (MP3, WAV, etc). Transcoded to 8kHz ulaw automatically.";
|
||||||
|
};
|
||||||
|
sort = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "random" "alphabetical" ];
|
||||||
|
default = "random";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
sipTrunks = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = "External SIP provider trunks, keyed by a short name (e.g. \"provider-a\").";
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
host = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "SIP provider hostname or IP address. Use hostFile to read from a file.";
|
||||||
|
};
|
||||||
|
hostFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the SIP provider hostname. Takes precedence over host.";
|
||||||
|
};
|
||||||
|
username = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "SIP account username. Use usernameFile to read from a file.";
|
||||||
|
};
|
||||||
|
usernameFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the SIP account username. Takes precedence over username.";
|
||||||
|
};
|
||||||
|
password = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "SIP account password. Use passwordFile to read from a file.";
|
||||||
|
};
|
||||||
|
passwordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the SIP account password. Takes precedence over password.";
|
||||||
|
};
|
||||||
|
transport = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "udp" "tcp" ];
|
||||||
|
default = "udp";
|
||||||
|
};
|
||||||
|
callerId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Outbound caller ID number presented to the provider. Leave empty to use provider default. Use callerIdFile to read from a file.";
|
||||||
|
};
|
||||||
|
callerIdFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the outbound caller ID. Takes precedence over callerId.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
sharedMailbox = lib.mkOption {
|
||||||
|
default = null;
|
||||||
|
description = "Shared voicemail mailbox accessible by all phones (family answering machine).";
|
||||||
|
type = lib.types.nullOr (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
mailboxId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "200";
|
||||||
|
description = "Numeric mailbox ID used internally in voicemail.conf and VoiceMail(). Must be numeric.";
|
||||||
|
};
|
||||||
|
checkExtension = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "*98";
|
||||||
|
description = "Extension dialled to check this shared mailbox directly (via VoiceMailMain).";
|
||||||
|
};
|
||||||
|
displayName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Shared";
|
||||||
|
description = "Name shown in voicemail configuration.";
|
||||||
|
};
|
||||||
|
greeting = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
dids = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = "Inbound DID routing. Each DID must reference a key from sipTrunks.";
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
number = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "DID number in E.164 format (e.g. \"+4912345678\"). Use numberFile to read from a file.";
|
||||||
|
};
|
||||||
|
numberFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the DID number. Takes precedence over number.";
|
||||||
|
};
|
||||||
|
trunk = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Key of the sipTrunks entry this DID arrives on.";
|
||||||
|
};
|
||||||
|
displayName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Human-readable label for this DID (informational only).";
|
||||||
|
};
|
||||||
|
routing = lib.mkOption {
|
||||||
|
description = "How inbound calls on this DID are distributed to phones.";
|
||||||
|
type = lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
type = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "all" "person" "persons" ];
|
||||||
|
description = ''
|
||||||
|
all — ring all phones (sharedPhones + all persons) on their L2 line
|
||||||
|
person — ring a single person on their L1 line
|
||||||
|
persons — ring a list of persons on their L2 line
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
person = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Person key for routing.type = \"person\".";
|
||||||
|
};
|
||||||
|
persons = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [];
|
||||||
|
description = "Person keys for routing.type = \"persons\".";
|
||||||
|
};
|
||||||
|
timeout = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Seconds to ring before going to voicemail (or hanging up).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
mailbox = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "shared" "person" "none" ];
|
||||||
|
default = "shared";
|
||||||
|
description = ''
|
||||||
|
shared — go to sharedMailbox on no answer (requires sharedMailbox to be set)
|
||||||
|
person — go to the routed person's mailbox on no answer (only valid with routing.type = "person")
|
||||||
|
none — hang up on no answer
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
musicOnHold = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
MOH class name to play to the caller while phones ring, instead of ringback.
|
||||||
|
Must match a key in mohClasses. null = standard ringback.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
extensions = lib.mkOption {
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Extra extensions: page groups and custom app entries.
|
||||||
|
Line extensions are auto-generated from sharedPhones and persons — do not declare them here.
|
||||||
|
'';
|
||||||
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
mode = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "page" "app" ];
|
||||||
|
default = "page";
|
||||||
|
description = ''
|
||||||
|
Extension mode:
|
||||||
|
- "page": one-way announcement to all phones
|
||||||
|
- "app": custom Asterisk dialplan application
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
displayName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
};
|
||||||
|
app = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Verbatim Asterisk dialplan app. Required for mode = \"app\".";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
95
modules/voip/phones.nix
Normal file
95
modules/voip/phones.nix
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{ lib }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# Per-model config. Adding a new hardware model:
|
||||||
|
# 1. Add an entry here (PBX-relevant fields only)
|
||||||
|
# 2. Add a provisioning template in ./provisioning/templates/<model>.nix
|
||||||
|
# exporting: desktopSize, thumbnailSize, mkConfig, mkDialplan
|
||||||
|
models = {
|
||||||
|
"cisco-8961" = {
|
||||||
|
endpointTemplate = "endpoint-cisco-8961";
|
||||||
|
maxContacts = 1;
|
||||||
|
hasProvisioning = true;
|
||||||
|
hasIntercom = true; # auto-answer speakerphone line
|
||||||
|
hasMultiLine = true; # separate L2 line for family/shared DID
|
||||||
|
};
|
||||||
|
"sip-client" = {
|
||||||
|
endpointTemplate = "endpoint-generic";
|
||||||
|
maxContacts = 1;
|
||||||
|
hasProvisioning = false;
|
||||||
|
hasIntercom = false;
|
||||||
|
hasMultiLine = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Shared option set for a physical phone device.
|
||||||
|
# isPersonPhone = true → no extension/displayName fields (inherited from person)
|
||||||
|
# isPersonPhone = false → includes extension and displayName
|
||||||
|
phoneDeviceOptions = isPersonPhone: {
|
||||||
|
model = lib.mkOption {
|
||||||
|
type = lib.types.enum (lib.attrNames models);
|
||||||
|
description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file).";
|
||||||
|
};
|
||||||
|
label = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Label shown on the phone screen (max ~12 chars for Cisco). Required for provisioned hardware phones.";
|
||||||
|
};
|
||||||
|
password = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "SIP registration password.";
|
||||||
|
};
|
||||||
|
} // lib.optionalAttrs (!isPersonPhone) {
|
||||||
|
extension = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Internal extension number for this shared phone.";
|
||||||
|
};
|
||||||
|
displayName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Name shown in the phone directory.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Unified view of all physical devices, keyed by SIP identity (MAC or username).
|
||||||
|
# Each entry carries the fields needed by sub-modules without them having to
|
||||||
|
# know about sharedPhones vs persons.
|
||||||
|
mkAllPhones = cfg:
|
||||||
|
lib.mapAttrs (key: p: {
|
||||||
|
inherit (p) model label password;
|
||||||
|
extension = p.extension;
|
||||||
|
displayName = p.displayName;
|
||||||
|
personKey = null;
|
||||||
|
mailboxExt = null; # shared phones have no personal mailbox
|
||||||
|
}) cfg.sharedPhones
|
||||||
|
//
|
||||||
|
lib.foldlAttrs (acc: personKey: person:
|
||||||
|
acc // lib.mapAttrs (_key: ph: {
|
||||||
|
inherit (ph) model label password;
|
||||||
|
extension = person.extension;
|
||||||
|
displayName = person.displayName;
|
||||||
|
personKey = personKey;
|
||||||
|
mailboxExt = if person.mailbox then person.extension else null;
|
||||||
|
}) person.phones
|
||||||
|
) {} cfg.persons;
|
||||||
|
|
||||||
|
# Auto-generate intercom extensions for provisioned phones.
|
||||||
|
mkIntercomEntries = cfg: allPhones:
|
||||||
|
if cfg.intercomPrefix == null then []
|
||||||
|
else lib.concatLists (lib.mapAttrsToList (key: phone:
|
||||||
|
let m = models.${phone.model}; in
|
||||||
|
lib.optional m.hasIntercom {
|
||||||
|
extension = "${cfg.intercomPrefix}${phone.extension}";
|
||||||
|
endpoint = "${key}-intercom";
|
||||||
|
phoneKey = key;
|
||||||
|
target = phone.extension;
|
||||||
|
displayName = "Intercom ${phone.displayName}";
|
||||||
|
password = phone.password;
|
||||||
|
endpointTemplate = m.endpointTemplate;
|
||||||
|
maxContacts = m.maxContacts;
|
||||||
|
}
|
||||||
|
) allPhones);
|
||||||
|
|
||||||
|
in {
|
||||||
|
inherit models phoneDeviceOptions mkAllPhones mkIntercomEntries;
|
||||||
|
}
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
{ lib, pkgs, cfg, models, allPhones, backgroundEntries }:
|
|
||||||
|
|
||||||
let
|
|
||||||
hasTrunk = cfg.sipTrunks != {};
|
|
||||||
|
|
||||||
# Page extensions for the intercom button auto-dial
|
|
||||||
pageExtension =
|
|
||||||
let pages = lib.attrNames (lib.filterAttrs (_: e: e.mode == "page") cfg.extensions);
|
|
||||||
in if pages != [] then lib.head pages else null;
|
|
||||||
|
|
||||||
hasIntercomButton = cfg.intercomPrefix != null && pageExtension != null;
|
|
||||||
|
|
||||||
# Collect all internal extension numbers to generate exact-match patterns.
|
|
||||||
# This tells the phone "this number is complete" so it dials immediately
|
|
||||||
# rather than waiting for the timeout or firing on the first digit.
|
|
||||||
allExtensions = lib.unique (
|
|
||||||
lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones
|
|
||||||
++ lib.mapAttrsToList (_: p: p.extension) cfg.persons
|
|
||||||
);
|
|
||||||
|
|
||||||
# Star extensions: intercom prefix + each extension, plus custom extensions.
|
|
||||||
allStarExtensions =
|
|
||||||
(lib.optionals (cfg.intercomPrefix != null)
|
|
||||||
(map (ext: "${cfg.intercomPrefix}${ext}") allExtensions))
|
|
||||||
++ lib.attrNames cfg.extensions
|
|
||||||
++ lib.optional (cfg.sharedMailbox != null) cfg.sharedMailbox.checkExtension;
|
|
||||||
|
|
||||||
# Generate the dial template for a phone, given its intercom lineIndex.
|
|
||||||
# The lineIndex is derived from familyLineEnabled — the same condition used
|
|
||||||
# in the Cisco template for button layout, so both are always in sync.
|
|
||||||
#
|
|
||||||
# Pattern evaluation: the phone tests templates top-to-bottom and dials as
|
|
||||||
# soon as digits match a pattern with timeout="0", or after the timeout
|
|
||||||
# elapses for patterns with timeout > 0. Explicit patterns must come before
|
|
||||||
# the catch-all "." so the phone doesn't fire early on a partial match.
|
|
||||||
mkDialplanXml = key: intercomLineIndex:
|
|
||||||
let
|
|
||||||
# Stable UUID derived from a hash of all inputs that affect this dialplan.
|
|
||||||
# Changes whenever extensions, trunk config, or intercom config changes,
|
|
||||||
# causing phones to reload the file. Format: 8-4-4-4-12 hex chars.
|
|
||||||
h = builtins.hashString "sha256" (builtins.toJSON {
|
|
||||||
inherit key allExtensions allStarExtensions hasTrunk hasIntercomButton intercomLineIndex;
|
|
||||||
});
|
|
||||||
versionStamp = "${builtins.substring 0 8 h}-${builtins.substring 8 4 h}-${builtins.substring 12 4 h}-${builtins.substring 16 4 h}-${builtins.substring 20 12 h}";
|
|
||||||
|
|
||||||
# Exact-match a complete extension number — dial immediately.
|
|
||||||
extTemplate = ext: " <TEMPLATE match=\"${ext}\" timeout=\"0\" />";
|
|
||||||
in ''
|
|
||||||
<dialTemplate>
|
|
||||||
<versionStamp>${versionStamp}</versionStamp>
|
|
||||||
${lib.optionalString hasIntercomButton
|
|
||||||
" <TEMPLATE match=\"\" timeout=\"0\" rewrite=\"${pageExtension}\" line=\"${toString intercomLineIndex}\" />"}
|
|
||||||
${lib.concatMapStrings (ext: extTemplate ext + "\n") allExtensions}
|
|
||||||
${lib.concatMapStrings (ext: extTemplate ext + "\n") allStarExtensions}
|
|
||||||
<TEMPLATE match=".." timeout="5" />
|
|
||||||
</dialTemplate>
|
|
||||||
'';
|
|
||||||
|
|
||||||
in
|
|
||||||
pkgs.linkFarm "voip-tftp-root" (
|
|
||||||
lib.concatLists (lib.mapAttrsToList (key: phone:
|
|
||||||
let m = models.${phone.model}; in
|
|
||||||
lib.optionals m.hasProvisioning (
|
|
||||||
let
|
|
||||||
upperKey = lib.toUpper key;
|
|
||||||
familyLineEnabled = hasTrunk && phone.personKey != null;
|
|
||||||
intercomLineIndex = if familyLineEnabled then 3 else 2;
|
|
||||||
dialplanFilename = "dialplan-${upperKey}.xml";
|
|
||||||
|
|
||||||
xml = m.template ({
|
|
||||||
mac = key;
|
|
||||||
inherit (phone) label password displayName;
|
|
||||||
serverAddress = cfg.serverAddress;
|
|
||||||
ntpServer = cfg.ntpServer;
|
|
||||||
sipPort = cfg.sipPort;
|
|
||||||
directoryPort = cfg.directoryPort;
|
|
||||||
dialplanFile = dialplanFilename;
|
|
||||||
} // lib.optionalAttrs (cfg.intercomPrefix != null) {
|
|
||||||
intercomEnabled = true;
|
|
||||||
intercomPassword = phone.password;
|
|
||||||
} // lib.optionalAttrs familyLineEnabled {
|
|
||||||
inherit familyLineEnabled;
|
|
||||||
familyLineLabel =
|
|
||||||
if cfg.sharedMailbox != null
|
|
||||||
then cfg.sharedMailbox.displayName
|
|
||||||
else "Familie";
|
|
||||||
familyLinePassword = phone.password;
|
|
||||||
});
|
|
||||||
in [
|
|
||||||
{ name = "SEP${upperKey}.cnf.xml";
|
|
||||||
path = pkgs.writeText "SEP${upperKey}.cnf.xml" xml; }
|
|
||||||
{ name = dialplanFilename;
|
|
||||||
path = pkgs.writeText dialplanFilename (mkDialplanXml key intercomLineIndex); }
|
|
||||||
]
|
|
||||||
)
|
|
||||||
) allPhones)
|
|
||||||
++ backgroundEntries
|
|
||||||
)
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
{ lib, pkgs, cfg, models, allPhones }:
|
{ lib, pkgs, cfg, templates, allPhones }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
|
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
|
||||||
sizeConfigs = lib.unique (lib.filter (s: s.desktop != null)
|
sizeConfigs = lib.unique (
|
||||||
(lib.mapAttrsToList (_: phone:
|
lib.concatLists (lib.mapAttrsToList (_: phone:
|
||||||
let m = models.${phone.model}; in
|
if lib.hasAttr phone.model templates
|
||||||
{ desktop = m.desktopSize; thumbnail = m.thumbnailSize; }
|
then let t = templates.${phone.model}; in
|
||||||
|
[{ desktop = t.desktopSize; thumbnail = t.thumbnailSize; }]
|
||||||
|
else []
|
||||||
) allPhones));
|
) allPhones));
|
||||||
|
|
||||||
# Parse "WxH" or "WxHxD" into width and height
|
# Parse "WxH" or "WxHxD" into width and height
|
||||||
70
modules/voip/provisioning/default.nix
Normal file
70
modules/voip/provisioning/default.nix
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
{ lib, pkgs, cfg, models, allPhones }:
|
||||||
|
|
||||||
|
let
|
||||||
|
hasTrunk = cfg.sipTrunks != {};
|
||||||
|
|
||||||
|
# Import provisioning templates for models that support it.
|
||||||
|
# Each template exports: desktopSize, thumbnailSize, mkFiles
|
||||||
|
templates = lib.mapAttrs (modelName: _:
|
||||||
|
import ./templates/${modelName}.nix { inherit lib; }
|
||||||
|
) (lib.filterAttrs (_: m: m.hasProvisioning) models);
|
||||||
|
|
||||||
|
# Page extensions for the intercom button auto-dial
|
||||||
|
pageExtension =
|
||||||
|
let pages = lib.attrNames (lib.filterAttrs (_: e: e.mode == "page") cfg.extensions);
|
||||||
|
in if pages != [] then lib.head pages else null;
|
||||||
|
|
||||||
|
hasIntercomButton = cfg.intercomPrefix != null && pageExtension != null;
|
||||||
|
|
||||||
|
# Collect all internal extension numbers to generate exact-match patterns.
|
||||||
|
# This tells the phone "this number is complete" so it dials immediately
|
||||||
|
# rather than waiting for the timeout or firing on the first digit.
|
||||||
|
allExtensions = lib.unique (
|
||||||
|
lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones
|
||||||
|
++ lib.mapAttrsToList (_: p: p.extension) cfg.persons
|
||||||
|
);
|
||||||
|
|
||||||
|
# Star extensions: intercom prefix + each extension, plus custom extensions.
|
||||||
|
allStarExtensions =
|
||||||
|
(lib.optionals (cfg.intercomPrefix != null)
|
||||||
|
(map (ext: "${cfg.intercomPrefix}${ext}") allExtensions))
|
||||||
|
++ lib.attrNames cfg.extensions
|
||||||
|
++ lib.optional (cfg.sharedMailbox != null) cfg.sharedMailbox.checkExtension;
|
||||||
|
|
||||||
|
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg templates allPhones; };
|
||||||
|
|
||||||
|
in
|
||||||
|
pkgs.linkFarm "voip-tftp-root" (
|
||||||
|
lib.concatLists (lib.mapAttrsToList (key: phone:
|
||||||
|
let m = models.${phone.model}; in
|
||||||
|
lib.optionals m.hasProvisioning (
|
||||||
|
let
|
||||||
|
t = templates.${phone.model};
|
||||||
|
familyLineEnabled = hasTrunk && phone.personKey != null;
|
||||||
|
intercomLineIndex = if familyLineEnabled then 3 else 2;
|
||||||
|
|
||||||
|
files = t.mkFiles ({
|
||||||
|
mac = key;
|
||||||
|
inherit (phone) label password displayName;
|
||||||
|
serverAddress = cfg.serverAddress;
|
||||||
|
ntpServer = cfg.ntpServer;
|
||||||
|
sipPort = cfg.sipPort;
|
||||||
|
directoryPort = cfg.directoryPort;
|
||||||
|
inherit allExtensions allStarExtensions hasTrunk hasIntercomButton pageExtension intercomLineIndex;
|
||||||
|
} // lib.optionalAttrs (cfg.intercomPrefix != null) {
|
||||||
|
intercomEnabled = true;
|
||||||
|
intercomPassword = phone.password;
|
||||||
|
} // lib.optionalAttrs familyLineEnabled {
|
||||||
|
inherit familyLineEnabled;
|
||||||
|
familyLineLabel =
|
||||||
|
if cfg.sharedMailbox != null
|
||||||
|
then cfg.sharedMailbox.displayName
|
||||||
|
else "Familie";
|
||||||
|
familyLinePassword = phone.password;
|
||||||
|
});
|
||||||
|
in
|
||||||
|
map (f: { inherit (f) name; path = pkgs.writeText f.name f.content; }) files
|
||||||
|
)
|
||||||
|
) allPhones)
|
||||||
|
++ backgroundEntries
|
||||||
|
)
|
||||||
276
modules/voip/provisioning/templates/cisco-8961.nix
Normal file
276
modules/voip/provisioning/templates/cisco-8961.nix
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
{ lib }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cisco = import ./cisco-base.nix { inherit lib; };
|
||||||
|
in {
|
||||||
|
desktopSize = "640x480x24";
|
||||||
|
thumbnailSize = "123x111";
|
||||||
|
|
||||||
|
# Return a list of { name, content } provisioning files for this phone.
|
||||||
|
# provisioning/default.nix wraps each with pkgs.writeText for the linkFarm.
|
||||||
|
mkFiles =
|
||||||
|
{ mac, label, displayName, password, serverAddress, ntpServer
|
||||||
|
, sipPort ? 5060
|
||||||
|
, directoryPort ? 8080
|
||||||
|
, familyLineEnabled ? false
|
||||||
|
, familyLineLabel ? "Familie"
|
||||||
|
, familyLinePassword ? ""
|
||||||
|
, intercomEnabled ? false
|
||||||
|
, intercomPassword ? ""
|
||||||
|
, intercomLineIndex ? 2
|
||||||
|
, allExtensions ? []
|
||||||
|
, allStarExtensions ? []
|
||||||
|
, hasTrunk ? false
|
||||||
|
, hasIntercomButton ? false
|
||||||
|
, pageExtension ? null
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Line button assignments:
|
||||||
|
# button 1 / lineIndex 1 — personal/location L1 line (always present)
|
||||||
|
# button 2 / lineIndex 2 — family L2 line (when familyLineEnabled)
|
||||||
|
# button N / lineIndex N — intercom (when intercomEnabled; N = 2 or 3)
|
||||||
|
intercomButton = if familyLineEnabled then 3 else 2;
|
||||||
|
|
||||||
|
dialplanFile = cisco.dialplanFilename mac;
|
||||||
|
|
||||||
|
configXml = ''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<device>
|
||||||
|
<deviceProtocol>SIP</deviceProtocol>
|
||||||
|
<sshUserId>admin</sshUserId>
|
||||||
|
<sshPassword>password</sshPassword>
|
||||||
|
<devicePool>
|
||||||
|
<dateTimeSetting>
|
||||||
|
<dateTemplate>D.M.YA</dateTemplate>
|
||||||
|
<timeZone>Central Europe Standard/Daylight Time</timeZone>
|
||||||
|
<ntps>
|
||||||
|
<ntp>
|
||||||
|
<name>${ntpServer}</name>
|
||||||
|
</ntp>
|
||||||
|
</ntps>
|
||||||
|
</dateTimeSetting>
|
||||||
|
<callManagerGroup>
|
||||||
|
<members>
|
||||||
|
<member priority="0">
|
||||||
|
<callManager>
|
||||||
|
<ports>
|
||||||
|
<sipPort>${toString sipPort}</sipPort>
|
||||||
|
</ports>
|
||||||
|
<processNodeName>${serverAddress}</processNodeName>
|
||||||
|
</callManager>
|
||||||
|
</member>
|
||||||
|
</members>
|
||||||
|
</callManagerGroup>
|
||||||
|
</devicePool>
|
||||||
|
<sipProfile>
|
||||||
|
<sipProxies>
|
||||||
|
<backupProxy></backupProxy>
|
||||||
|
<backupProxyPort>5060</backupProxyPort>
|
||||||
|
<emergencyProxy></emergencyProxy>
|
||||||
|
<emergencyProxyPort></emergencyProxyPort>
|
||||||
|
<outboundProxy></outboundProxy>
|
||||||
|
<outboundProxyPort></outboundProxyPort>
|
||||||
|
<registerWithProxy>true</registerWithProxy>
|
||||||
|
</sipProxies>
|
||||||
|
<sipCallFeatures>
|
||||||
|
<cnfJoinEnabled>true</cnfJoinEnabled>
|
||||||
|
<callForwardURI>x-serviceuri-cfwdall</callForwardURI>
|
||||||
|
<callPickupURI>x-cisco-serviceuri-pickup</callPickupURI>
|
||||||
|
<callPickupListURI>x-cisco-serviceuri-opickup</callPickupListURI>
|
||||||
|
<callPickupGroupURI>x-cisco-serviceuri-gpickup</callPickupGroupURI>
|
||||||
|
<meetMeServiceURI>x-cisco-serviceuri-meetme</meetMeServiceURI>
|
||||||
|
<abbreviatedDialURI>x-cisco-serviceuri-abbrdial</abbreviatedDialURI>
|
||||||
|
<callHoldRingback>2</callHoldRingback>
|
||||||
|
<anonymousCallBlock>2</anonymousCallBlock>
|
||||||
|
<callerIdBlocking>2</callerIdBlocking>
|
||||||
|
<dndControl>0</dndControl>
|
||||||
|
<remoteCcEnable>true</remoteCcEnable>
|
||||||
|
</sipCallFeatures>
|
||||||
|
<sipStack>
|
||||||
|
<sipInviteRetx>6</sipInviteRetx>
|
||||||
|
<sipRetx>10</sipRetx>
|
||||||
|
<timerInviteExpires>180</timerInviteExpires>
|
||||||
|
<timerRegisterExpires>3600</timerRegisterExpires>
|
||||||
|
<timerRegisterDelta>5</timerRegisterDelta>
|
||||||
|
<timerKeepAliveExpires>120</timerKeepAliveExpires>
|
||||||
|
<timerSubscribeExpires>120</timerSubscribeExpires>
|
||||||
|
<timerSubscribeDelta>5</timerSubscribeDelta>
|
||||||
|
<timerT1>500</timerT1>
|
||||||
|
<timerT2>4000</timerT2>
|
||||||
|
<maxRedirects>70</maxRedirects>
|
||||||
|
<remotePartyID>false</remotePartyID>
|
||||||
|
<userInfo>None</userInfo>
|
||||||
|
</sipStack>
|
||||||
|
<transferOnhookEnabled>false</transferOnhookEnabled>
|
||||||
|
<kpml>3</kpml>
|
||||||
|
<phoneLabel>${(builtins.substring 0 12 label)}</phoneLabel>
|
||||||
|
<stutterMsgWaiting>1</stutterMsgWaiting>
|
||||||
|
<callStats>false</callStats>
|
||||||
|
<sipLines>
|
||||||
|
<line button="1" lineIndex="1">
|
||||||
|
<featureID>9</featureID>
|
||||||
|
<featureLabel>${displayName}</featureLabel>
|
||||||
|
<proxy>USECALLMANAGER</proxy>
|
||||||
|
<port>${toString sipPort}</port>
|
||||||
|
<name>${mac}</name>
|
||||||
|
<displayName>${displayName}</displayName>
|
||||||
|
<autoAnswer>
|
||||||
|
<autoAnswerEnabled>2</autoAnswerEnabled>
|
||||||
|
</autoAnswer>
|
||||||
|
<callWaiting>3</callWaiting>
|
||||||
|
<authName>${mac}</authName>
|
||||||
|
<authPassword>${password}</authPassword>
|
||||||
|
<messageWaitingLampPolicy>1</messageWaitingLampPolicy>
|
||||||
|
<messagesNumber>*97</messagesNumber>
|
||||||
|
<contact>${mac}</contact>
|
||||||
|
<forwardCallInfoDisplay>
|
||||||
|
<callerName>true</callerName>
|
||||||
|
<callerNumber>true</callerNumber>
|
||||||
|
<redirectedNumber>true</redirectedNumber>
|
||||||
|
<dialedNumber>true</dialedNumber>
|
||||||
|
</forwardCallInfoDisplay>
|
||||||
|
</line>
|
||||||
|
${if familyLineEnabled then ''
|
||||||
|
<line button="2" lineIndex="2">
|
||||||
|
<featureID>9</featureID>
|
||||||
|
<featureLabel>${familyLineLabel}</featureLabel>
|
||||||
|
<proxy>USECALLMANAGER</proxy>
|
||||||
|
<port>${toString sipPort}</port>
|
||||||
|
<name>${mac}-l2</name>
|
||||||
|
<displayName>${familyLineLabel}</displayName>
|
||||||
|
<autoAnswer>
|
||||||
|
<autoAnswerEnabled>2</autoAnswerEnabled>
|
||||||
|
</autoAnswer>
|
||||||
|
<callWaiting>3</callWaiting>
|
||||||
|
<authName>${mac}-l2</authName>
|
||||||
|
<authPassword>${familyLinePassword}</authPassword>
|
||||||
|
<messageWaitingLampPolicy>3</messageWaitingLampPolicy>
|
||||||
|
<messagesNumber>*97</messagesNumber>
|
||||||
|
<contact>${mac}-l2</contact>
|
||||||
|
<forwardCallInfoDisplay>
|
||||||
|
<callerName>true</callerName>
|
||||||
|
<callerNumber>true</callerNumber>
|
||||||
|
<redirectedNumber>true</redirectedNumber>
|
||||||
|
<dialedNumber>true</dialedNumber>
|
||||||
|
</forwardCallInfoDisplay>
|
||||||
|
</line>
|
||||||
|
'' else ""}${if intercomEnabled then ''
|
||||||
|
<line button="${toString intercomButton}" lineIndex="${toString intercomButton}">
|
||||||
|
<featureID>23</featureID>
|
||||||
|
<featureLabel>Intercom</featureLabel>
|
||||||
|
<proxy>USECALLMANAGER</proxy>
|
||||||
|
<port>${toString sipPort}</port>
|
||||||
|
<name>${mac}-intercom</name>
|
||||||
|
<displayName>Intercom</displayName>
|
||||||
|
<autoAnswer>
|
||||||
|
<autoAnswerEnabled>3</autoAnswerEnabled>
|
||||||
|
<autoAnswerMode>Auto Answer with Speakerphone</autoAnswerMode>
|
||||||
|
</autoAnswer>
|
||||||
|
<callWaiting>3</callWaiting>
|
||||||
|
<authName>${mac}-intercom</authName>
|
||||||
|
<authPassword>${intercomPassword}</authPassword>
|
||||||
|
<maxNumCalls>1</maxNumCalls>
|
||||||
|
<busyTrigger>1</busyTrigger>
|
||||||
|
<contact>${mac}-intercom</contact>
|
||||||
|
</line>
|
||||||
|
'' else ""} </sipLines>
|
||||||
|
<voipControlPort>${toString sipPort}</voipControlPort>
|
||||||
|
<startMediaPort>16348</startMediaPort>
|
||||||
|
<stopMediaPort>20134</stopMediaPort>
|
||||||
|
<dscpForAudio>184</dscpForAudio>
|
||||||
|
<dialTemplate>${dialplanFile}</dialTemplate>
|
||||||
|
</sipProfile>
|
||||||
|
<MissedCallLoggingOption>1</MissedCallLoggingOption>
|
||||||
|
<commonProfile>
|
||||||
|
<phonePassword></phonePassword>
|
||||||
|
<backgroundImageAccess>true</backgroundImageAccess>
|
||||||
|
<callLogBlfEnabled>2</callLogBlfEnabled>
|
||||||
|
</commonProfile>
|
||||||
|
<loadInformation>sip8961.9-4-2ES-14</loadInformation>
|
||||||
|
<vendorConfig>
|
||||||
|
<webAccess>0</webAccess>
|
||||||
|
<settingsAccess>1</settingsAccess>
|
||||||
|
<autoSelectLineEnable>0</autoSelectLineEnable>
|
||||||
|
<loggingDisplay>1</loggingDisplay>
|
||||||
|
<daysDisplayNotActive>1,2,3,4,5,6,7</daysDisplayNotActive>
|
||||||
|
<sshAccess>0</sshAccess>
|
||||||
|
<displayOnTime>00:00</displayOnTime>
|
||||||
|
<displayOnDuration>00:00</displayOnDuration>
|
||||||
|
<displayIdleTimeout>00:05</displayIdleTimeout>
|
||||||
|
<displayOnWhenIncomingCall>1</displayOnWhenIncomingCall>
|
||||||
|
</vendorConfig>
|
||||||
|
<userLocale>
|
||||||
|
<langCode>en_US</langCode>
|
||||||
|
<winCharSet>utf-8</winCharSet>
|
||||||
|
</userLocale>
|
||||||
|
<deviceSecurityMode>1</deviceSecurityMode>
|
||||||
|
<authenticationURL></authenticationURL>
|
||||||
|
<directoryURL>http://${serverAddress}:${toString directoryPort}/directory.xml</directoryURL>
|
||||||
|
<messagesURL />
|
||||||
|
<servicesURL></servicesURL>
|
||||||
|
<idleURL></idleURL>
|
||||||
|
<informationURL></informationURL>
|
||||||
|
<phoneServices useHTTPS="false">
|
||||||
|
<provisioning>2</provisioning>
|
||||||
|
<phoneService type="1" category="0">
|
||||||
|
<name>Missed Calls</name>
|
||||||
|
<url>Application:Cisco/MissedCalls</url>
|
||||||
|
<vendor></vendor>
|
||||||
|
<version></version>
|
||||||
|
</phoneService>
|
||||||
|
<phoneService type="1" category="0">
|
||||||
|
<name>Received Calls</name>
|
||||||
|
<url>Application:Cisco/ReceivedCalls</url>
|
||||||
|
<vendor></vendor>
|
||||||
|
<version></version>
|
||||||
|
</phoneService>
|
||||||
|
<phoneService type="1" category="0">
|
||||||
|
<name>Placed Calls</name>
|
||||||
|
<url>Application:Cisco/PlacedCalls</url>
|
||||||
|
<vendor></vendor>
|
||||||
|
<version></version>
|
||||||
|
</phoneService>
|
||||||
|
<phoneService type="2" category="0">
|
||||||
|
<name>Voicemail</name>
|
||||||
|
<url>Application:Cisco/Voicemail</url>
|
||||||
|
<vendor></vendor>
|
||||||
|
<version></version>
|
||||||
|
</phoneService>
|
||||||
|
</phoneServices>
|
||||||
|
<proxyServerURL></proxyServerURL>
|
||||||
|
<transportLayerProtocol>1</transportLayerProtocol>
|
||||||
|
<capfAuthMode>0</capfAuthMode>
|
||||||
|
<capfList>
|
||||||
|
<capf>
|
||||||
|
<phonePort>3804</phonePort>
|
||||||
|
</capf>
|
||||||
|
</capfList>
|
||||||
|
<encrConfig>false</encrConfig>
|
||||||
|
</device>
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Dial template: the phone tests patterns top-to-bottom and dials as soon
|
||||||
|
# as digits match a pattern with timeout="0", or after the timeout for
|
||||||
|
# timeout > 0. Explicit patterns must come before the catch-all.
|
||||||
|
h = builtins.hashString "sha256" (builtins.toJSON {
|
||||||
|
inherit mac allExtensions allStarExtensions hasTrunk hasIntercomButton intercomLineIndex;
|
||||||
|
});
|
||||||
|
versionStamp = "${builtins.substring 0 8 h}-${builtins.substring 8 4 h}-${builtins.substring 12 4 h}-${builtins.substring 16 4 h}-${builtins.substring 20 12 h}";
|
||||||
|
extMatch = ext: " <TEMPLATE match=\"${ext}\" timeout=\"0\" />";
|
||||||
|
|
||||||
|
dialplanXml = ''
|
||||||
|
<dialTemplate>
|
||||||
|
<versionStamp>${versionStamp}</versionStamp>
|
||||||
|
${lib.optionalString hasIntercomButton
|
||||||
|
" <TEMPLATE match=\"\" timeout=\"0\" rewrite=\"${pageExtension}\" line=\"${toString intercomLineIndex}\" />"}
|
||||||
|
${lib.concatMapStrings (ext: extMatch ext + "\n") allExtensions}
|
||||||
|
${lib.concatMapStrings (ext: extMatch ext + "\n") allStarExtensions}
|
||||||
|
<TEMPLATE match=".." timeout="5" />
|
||||||
|
</dialTemplate>
|
||||||
|
'';
|
||||||
|
|
||||||
|
in [
|
||||||
|
{ name = cisco.configFilename mac; content = configXml; }
|
||||||
|
{ name = cisco.dialplanFilename mac; content = dialplanXml; }
|
||||||
|
];
|
||||||
|
}
|
||||||
11
modules/voip/provisioning/templates/cisco-base.nix
Normal file
11
modules/voip/provisioning/templates/cisco-base.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{ lib }:
|
||||||
|
|
||||||
|
# Shared conventions for Cisco IP Phone provisioning.
|
||||||
|
# Cisco phones identify config files by uppercase MAC with a "SEP" prefix.
|
||||||
|
{
|
||||||
|
# SEP<MAC>.cnf.xml — main phone configuration
|
||||||
|
configFilename = mac: "SEP${lib.toUpper mac}.cnf.xml";
|
||||||
|
|
||||||
|
# dialplan-<MAC>.xml — dial template (number patterns, timeouts)
|
||||||
|
dialplanFilename = mac: "dialplan-${lib.toUpper mac}.xml";
|
||||||
|
}
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
{ mac, label, displayName, password, serverAddress, ntpServer
|
|
||||||
, sipPort ? 5060
|
|
||||||
, directoryPort ? 8080
|
|
||||||
, familyLineEnabled ? false
|
|
||||||
, familyLineLabel ? "Familie"
|
|
||||||
, familyLinePassword ? ""
|
|
||||||
, intercomEnabled ? false
|
|
||||||
, intercomPassword ? ""
|
|
||||||
, dialplanFile ? "dialplan.xml"
|
|
||||||
}:
|
|
||||||
|
|
||||||
let
|
|
||||||
# Line button assignments:
|
|
||||||
# button 1 / lineIndex 1 — personal/location L1 line (always present)
|
|
||||||
# button 2 / lineIndex 2 — family L2 line (when familyLineEnabled)
|
|
||||||
# button N / lineIndex N — intercom (when intercomEnabled; N = 2 or 3)
|
|
||||||
intercomButton = if familyLineEnabled then 3 else 2;
|
|
||||||
intercomLineIndex = intercomButton;
|
|
||||||
in
|
|
||||||
''
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<device>
|
|
||||||
<deviceProtocol>SIP</deviceProtocol>
|
|
||||||
<sshUserId>admin</sshUserId>
|
|
||||||
<sshPassword>password</sshPassword>
|
|
||||||
<devicePool>
|
|
||||||
<dateTimeSetting>
|
|
||||||
<dateTemplate>D.M.YA</dateTemplate>
|
|
||||||
<timeZone>Central Europe Standard/Daylight Time</timeZone>
|
|
||||||
<ntps>
|
|
||||||
<ntp>
|
|
||||||
<name>${ntpServer}</name>
|
|
||||||
</ntp>
|
|
||||||
</ntps>
|
|
||||||
</dateTimeSetting>
|
|
||||||
<callManagerGroup>
|
|
||||||
<members>
|
|
||||||
<member priority="0">
|
|
||||||
<callManager>
|
|
||||||
<ports>
|
|
||||||
<sipPort>${toString sipPort}</sipPort>
|
|
||||||
</ports>
|
|
||||||
<processNodeName>${serverAddress}</processNodeName>
|
|
||||||
</callManager>
|
|
||||||
</member>
|
|
||||||
</members>
|
|
||||||
</callManagerGroup>
|
|
||||||
</devicePool>
|
|
||||||
<sipProfile>
|
|
||||||
<sipProxies>
|
|
||||||
<backupProxy></backupProxy>
|
|
||||||
<backupProxyPort>5060</backupProxyPort>
|
|
||||||
<emergencyProxy></emergencyProxy>
|
|
||||||
<emergencyProxyPort></emergencyProxyPort>
|
|
||||||
<outboundProxy></outboundProxy>
|
|
||||||
<outboundProxyPort></outboundProxyPort>
|
|
||||||
<registerWithProxy>true</registerWithProxy>
|
|
||||||
</sipProxies>
|
|
||||||
<sipCallFeatures>
|
|
||||||
<cnfJoinEnabled>true</cnfJoinEnabled>
|
|
||||||
<callForwardURI>x-serviceuri-cfwdall</callForwardURI>
|
|
||||||
<callPickupURI>x-cisco-serviceuri-pickup</callPickupURI>
|
|
||||||
<callPickupListURI>x-cisco-serviceuri-opickup</callPickupListURI>
|
|
||||||
<callPickupGroupURI>x-cisco-serviceuri-gpickup</callPickupGroupURI>
|
|
||||||
<meetMeServiceURI>x-cisco-serviceuri-meetme</meetMeServiceURI>
|
|
||||||
<abbreviatedDialURI>x-cisco-serviceuri-abbrdial</abbreviatedDialURI>
|
|
||||||
<callHoldRingback>2</callHoldRingback>
|
|
||||||
<anonymousCallBlock>2</anonymousCallBlock>
|
|
||||||
<callerIdBlocking>2</callerIdBlocking>
|
|
||||||
<dndControl>0</dndControl>
|
|
||||||
<remoteCcEnable>true</remoteCcEnable>
|
|
||||||
</sipCallFeatures>
|
|
||||||
<sipStack>
|
|
||||||
<sipInviteRetx>6</sipInviteRetx>
|
|
||||||
<sipRetx>10</sipRetx>
|
|
||||||
<timerInviteExpires>180</timerInviteExpires>
|
|
||||||
<timerRegisterExpires>3600</timerRegisterExpires>
|
|
||||||
<timerRegisterDelta>5</timerRegisterDelta>
|
|
||||||
<timerKeepAliveExpires>120</timerKeepAliveExpires>
|
|
||||||
<timerSubscribeExpires>120</timerSubscribeExpires>
|
|
||||||
<timerSubscribeDelta>5</timerSubscribeDelta>
|
|
||||||
<timerT1>500</timerT1>
|
|
||||||
<timerT2>4000</timerT2>
|
|
||||||
<maxRedirects>70</maxRedirects>
|
|
||||||
<remotePartyID>false</remotePartyID>
|
|
||||||
<userInfo>None</userInfo>
|
|
||||||
</sipStack>
|
|
||||||
<transferOnhookEnabled>false</transferOnhookEnabled>
|
|
||||||
<kpml>3</kpml>
|
|
||||||
<phoneLabel>${(builtins.substring 0 12 label)}</phoneLabel>
|
|
||||||
<stutterMsgWaiting>1</stutterMsgWaiting>
|
|
||||||
<callStats>false</callStats>
|
|
||||||
<sipLines>
|
|
||||||
<line button="1" lineIndex="1">
|
|
||||||
<featureID>9</featureID>
|
|
||||||
<featureLabel>${displayName}</featureLabel>
|
|
||||||
<proxy>USECALLMANAGER</proxy>
|
|
||||||
<port>${toString sipPort}</port>
|
|
||||||
<name>${mac}</name>
|
|
||||||
<displayName>${displayName}</displayName>
|
|
||||||
<autoAnswer>
|
|
||||||
<autoAnswerEnabled>2</autoAnswerEnabled>
|
|
||||||
</autoAnswer>
|
|
||||||
<callWaiting>3</callWaiting>
|
|
||||||
<authName>${mac}</authName>
|
|
||||||
<authPassword>${password}</authPassword>
|
|
||||||
<messageWaitingLampPolicy>1</messageWaitingLampPolicy>
|
|
||||||
<messagesNumber>*97</messagesNumber>
|
|
||||||
<contact>${mac}</contact>
|
|
||||||
<forwardCallInfoDisplay>
|
|
||||||
<callerName>true</callerName>
|
|
||||||
<callerNumber>true</callerNumber>
|
|
||||||
<redirectedNumber>true</redirectedNumber>
|
|
||||||
<dialedNumber>true</dialedNumber>
|
|
||||||
</forwardCallInfoDisplay>
|
|
||||||
</line>
|
|
||||||
${if familyLineEnabled then ''
|
|
||||||
<line button="2" lineIndex="2">
|
|
||||||
<featureID>9</featureID>
|
|
||||||
<featureLabel>${familyLineLabel}</featureLabel>
|
|
||||||
<proxy>USECALLMANAGER</proxy>
|
|
||||||
<port>${toString sipPort}</port>
|
|
||||||
<name>${mac}-l2</name>
|
|
||||||
<displayName>${familyLineLabel}</displayName>
|
|
||||||
<autoAnswer>
|
|
||||||
<autoAnswerEnabled>2</autoAnswerEnabled>
|
|
||||||
</autoAnswer>
|
|
||||||
<callWaiting>3</callWaiting>
|
|
||||||
<authName>${mac}-l2</authName>
|
|
||||||
<authPassword>${familyLinePassword}</authPassword>
|
|
||||||
<messageWaitingLampPolicy>3</messageWaitingLampPolicy>
|
|
||||||
<messagesNumber>*97</messagesNumber>
|
|
||||||
<contact>${mac}-l2</contact>
|
|
||||||
<forwardCallInfoDisplay>
|
|
||||||
<callerName>true</callerName>
|
|
||||||
<callerNumber>true</callerNumber>
|
|
||||||
<redirectedNumber>true</redirectedNumber>
|
|
||||||
<dialedNumber>true</dialedNumber>
|
|
||||||
</forwardCallInfoDisplay>
|
|
||||||
</line>
|
|
||||||
'' else ""}${if intercomEnabled then ''
|
|
||||||
<line button="${toString intercomButton}" lineIndex="${toString intercomLineIndex}">
|
|
||||||
<featureID>23</featureID>
|
|
||||||
<featureLabel>Intercom</featureLabel>
|
|
||||||
<proxy>USECALLMANAGER</proxy>
|
|
||||||
<port>${toString sipPort}</port>
|
|
||||||
<name>${mac}-intercom</name>
|
|
||||||
<displayName>Intercom</displayName>
|
|
||||||
<autoAnswer>
|
|
||||||
<autoAnswerEnabled>3</autoAnswerEnabled>
|
|
||||||
<autoAnswerMode>Auto Answer with Speakerphone</autoAnswerMode>
|
|
||||||
</autoAnswer>
|
|
||||||
<callWaiting>3</callWaiting>
|
|
||||||
<authName>${mac}-intercom</authName>
|
|
||||||
<authPassword>${intercomPassword}</authPassword>
|
|
||||||
<maxNumCalls>1</maxNumCalls>
|
|
||||||
<busyTrigger>1</busyTrigger>
|
|
||||||
<contact>${mac}-intercom</contact>
|
|
||||||
</line>
|
|
||||||
'' else ""} </sipLines>
|
|
||||||
<voipControlPort>${toString sipPort}</voipControlPort>
|
|
||||||
<startMediaPort>16348</startMediaPort>
|
|
||||||
<stopMediaPort>20134</stopMediaPort>
|
|
||||||
<dscpForAudio>184</dscpForAudio>
|
|
||||||
<dialTemplate>${dialplanFile}</dialTemplate>
|
|
||||||
</sipProfile>
|
|
||||||
<MissedCallLoggingOption>1</MissedCallLoggingOption>
|
|
||||||
<commonProfile>
|
|
||||||
<phonePassword></phonePassword>
|
|
||||||
<backgroundImageAccess>true</backgroundImageAccess>
|
|
||||||
<callLogBlfEnabled>2</callLogBlfEnabled>
|
|
||||||
</commonProfile>
|
|
||||||
<loadInformation>sip8961.9-4-2ES-14</loadInformation>
|
|
||||||
<vendorConfig>
|
|
||||||
<webAccess>0</webAccess>
|
|
||||||
<settingsAccess>1</settingsAccess>
|
|
||||||
<autoSelectLineEnable>0</autoSelectLineEnable>
|
|
||||||
<loggingDisplay>1</loggingDisplay>
|
|
||||||
<daysDisplayNotActive>1,2,3,4,5,6,7</daysDisplayNotActive>
|
|
||||||
<sshAccess>0</sshAccess>
|
|
||||||
<displayOnTime>00:00</displayOnTime>
|
|
||||||
<displayOnDuration>00:00</displayOnDuration>
|
|
||||||
<displayIdleTimeout>00:05</displayIdleTimeout>
|
|
||||||
<displayOnWhenIncomingCall>1</displayOnWhenIncomingCall>
|
|
||||||
</vendorConfig>
|
|
||||||
<userLocale>
|
|
||||||
<langCode>en_US</langCode>
|
|
||||||
<winCharSet>utf-8</winCharSet>
|
|
||||||
</userLocale>
|
|
||||||
<deviceSecurityMode>1</deviceSecurityMode>
|
|
||||||
<authenticationURL></authenticationURL>
|
|
||||||
<directoryURL>http://${serverAddress}:${toString directoryPort}/directory.xml</directoryURL>
|
|
||||||
<messagesURL />
|
|
||||||
<servicesURL></servicesURL>
|
|
||||||
<idleURL></idleURL>
|
|
||||||
<informationURL></informationURL>
|
|
||||||
<phoneServices useHTTPS="false">
|
|
||||||
<provisioning>2</provisioning>
|
|
||||||
<phoneService type="1" category="0">
|
|
||||||
<name>Missed Calls</name>
|
|
||||||
<url>Application:Cisco/MissedCalls</url>
|
|
||||||
<vendor></vendor>
|
|
||||||
<version></version>
|
|
||||||
</phoneService>
|
|
||||||
<phoneService type="1" category="0">
|
|
||||||
<name>Received Calls</name>
|
|
||||||
<url>Application:Cisco/ReceivedCalls</url>
|
|
||||||
<vendor></vendor>
|
|
||||||
<version></version>
|
|
||||||
</phoneService>
|
|
||||||
<phoneService type="1" category="0">
|
|
||||||
<name>Placed Calls</name>
|
|
||||||
<url>Application:Cisco/PlacedCalls</url>
|
|
||||||
<vendor></vendor>
|
|
||||||
<version></version>
|
|
||||||
</phoneService>
|
|
||||||
<phoneService type="2" category="0">
|
|
||||||
<name>Voicemail</name>
|
|
||||||
<url>Application:Cisco/Voicemail</url>
|
|
||||||
<vendor></vendor>
|
|
||||||
<version></version>
|
|
||||||
</phoneService>
|
|
||||||
</phoneServices>
|
|
||||||
<proxyServerURL></proxyServerURL>
|
|
||||||
<transportLayerProtocol>1</transportLayerProtocol>
|
|
||||||
<capfAuthMode>0</capfAuthMode>
|
|
||||||
<capfList>
|
|
||||||
<capf>
|
|
||||||
<phonePort>3804</phonePort>
|
|
||||||
</capf>
|
|
||||||
</capfList>
|
|
||||||
<encrConfig>false</encrConfig>
|
|
||||||
</device>
|
|
||||||
''
|
|
||||||
Loading…
Reference in a new issue