{ lib, pkgs, cfg, models, allPhones, intercomEntries }: let hasTrunk = cfg.sipTrunks != {}; # For HTML table cells: produces either the literal value or a @@/full/path@@ # marker that the nginx Lua handler opens directly at request time. rtv = val: file: if file != null then "@@${file}@@" else val; # For DOT node labels (end up in SVG): never use @@ markers — graphviz may # split long strings at hyphens across elements, breaking substitution # and corrupting the SVG. Show the key filename as a static label instead. rtvLabel = val: file: if file != null then baseNameOf file else val; # Sanitize a string for use as a DOT node identifier nid = prefix: s: "${prefix}_${lib.replaceStrings [ "-" "+" "*" "@" "." " " "/" ] [ "_" "p" "s" "at" "_" "_" "_" ] s}"; # Whether a phone gets an L2 line (same logic as asterisk.nix) phoneHasL2 = key: let phone = allPhones.${key}; in phone.personKey != null && phone.sharedLine && models.${phone.model}.hasMultiLine; # ── Nodes ─────────────────────────────────────────────────────────────── trunkNodes = lib.mapAttrsToList (name: t: '' ${nid "trunk" name} [label="${name}\n${rtvLabel t.host t.hostFile}" shape=box style="rounded,filled" fillcolor="#AED6F1"]'' ) cfg.sipTrunks; didNodes = lib.mapAttrsToList (id: didCfg: let numLabel = rtvLabel didCfg.number didCfg.numberFile; in '' ${nid "did" id} [label="${numLabel}\n${didCfg.displayName}" shape=box style="rounded,filled" fillcolor="#FAD7A0"]'' ) cfg.dids; personNodes = lib.mapAttrsToList (key: person: '' ${nid "person" key} [label="${person.displayName}\next. ${person.extension}" shape=ellipse style=filled fillcolor="#A9DFBF"]'' ) cfg.persons; phoneNodes = lib.mapAttrsToList (key: phone: let modelLabel = if phone.personKey != null then cfg.persons.${phone.personKey}.phones.${key}.model else cfg.sharedPhones.${key}.model; isSoftClient = modelLabel == "sip-client"; style = if isSoftClient then "dashed,filled" else "filled"; lines = [ phone.displayName ] ++ lib.optional (phone.personKey == null) "ext. ${phone.extension}" ++ [ modelLabel ] ++ lib.optional (!isSoftClient) "(${key})" ++ lib.optional isSoftClient key; in '' ${nid "phone" key} [label="${lib.concatStringsSep "\\n" lines}" shape=box style="${style}" fillcolor="#D5D8DC"]'' ) allPhones; mailboxNodes = lib.optional (cfg.sharedMailbox != null) '' ${nid "mbox" cfg.sharedMailbox.mailboxId} [label="${cfg.sharedMailbox.displayName}\nbox ${cfg.sharedMailbox.mailboxId}" shape=cylinder style=filled fillcolor="#D7BDE2"]'' ++ lib.mapAttrsToList (key: person: lib.optionalString person.mailbox '' ${nid "mbox" person.extension} [label="${person.displayName}\nbox ${person.extension}" shape=cylinder style=filled fillcolor="#D7BDE2"]'' ) cfg.persons; extensionNodes = lib.mapAttrsToList (ext: extCfg: let icon = if extCfg.mode == "page" then "📢 " else "⚙ "; in '' ${nid "ext" ext} [label="${ext}\n${extCfg.displayName}\n(${extCfg.mode})" shape=diamond style=filled fillcolor="#D6EAF8"]'' ) cfg.extensions; intercomNodes = map (ic: '' ${nid "ic" ic.extension} [label="${ic.extension}\n${ic.displayName}" shape=diamond style=filled fillcolor="#FDEBD0"]'' ) intercomEntries; # ── Edges ─────────────────────────────────────────────────────────────── # Trunk → DID trunkDidEdges = lib.mapAttrsToList (id: didCfg: '' ${nid "trunk" didCfg.trunk} -> ${nid "did" id}'' ) cfg.dids; # DID → phone targets (routing) didRoutingEdges = lib.concatLists (lib.mapAttrsToList (id: didCfg: let r = didCfg.routing; lbl = label: ''label="${label}" fontsize=9''; edge = target: lineLabel: '' ${nid "did" id} -> ${nid "phone" target} [${lbl lineLabel}]''; in if r.type == "all" then lib.mapAttrsToList (key: _: edge key (if phoneHasL2 key then "L2" else "L1") ) allPhones else if r.type == "person" then lib.mapAttrsToList (key: _: edge key "L1") cfg.persons.${r.person}.phones else # persons lib.concatMap (personKey: lib.mapAttrsToList (key: _: edge key "L2") cfg.persons.${personKey}.phones ) r.persons ) cfg.dids); # DID → mailbox (no-answer fallback, dashed) didMailboxEdges = lib.mapAttrsToList (id: didCfg: let r = didCfg.routing; dashAttr = ''style=dashed color="#999999" fontsize=9 label="no answer"''; in if didCfg.mailbox == "shared" then '' ${nid "did" id} -> ${nid "mbox" cfg.sharedMailbox.mailboxId} [${dashAttr}]'' else if didCfg.mailbox == "person" then '' ${nid "did" id} -> ${nid "mbox" cfg.persons.${r.person}.extension} [${dashAttr}]'' else "" ) cfg.dids; # Person → their phones personPhoneEdges = lib.concatLists (lib.mapAttrsToList (key: person: lib.mapAttrsToList (pkey: _: '' ${nid "person" key} -> ${nid "phone" pkey} [arrowhead=open]'' ) person.phones ) cfg.persons); # Intercom edges: phone → intercom extension intercomEdges = map (ic: '' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]'' ) intercomEntries; # Page extension → intercom-capable phones pagePhones = lib.filterAttrs (key: phone: models.${phone.model}.hasIntercom && cfg.intercomPrefix != null ) allPhones; pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg: lib.optionals (extCfg.mode == "page") (lib.mapAttrsToList (key: _: '' ${nid "ext" ext} -> ${nid "phone" key} [style=dotted color="#5D6D7E"]'' ) pagePhones) ) cfg.extensions); # ── Assemble DOT ──────────────────────────────────────────────────────── dotSource = '' digraph voip { rankdir=LR; graph [fontname="Helvetica,Arial,sans-serif" fontsize=11 splines=ortho]; node [fontname="Helvetica,Arial,sans-serif" fontsize=10]; edge [fontname="Helvetica,Arial,sans-serif" fontsize=9]; subgraph cluster_trunks { label="SIP Trunks"; style=filled; fillcolor="#EBF5FB"; ${lib.concatStringsSep "\n" trunkNodes} } subgraph cluster_dids { label="DIDs (Inbound)"; style=filled; fillcolor="#FEF9E7"; ${lib.concatStringsSep "\n" didNodes} } ${lib.optionalString (cfg.persons != {}) '' subgraph cluster_persons { label="Persons"; style=filled; fillcolor="#EAFAF1"; ${lib.concatStringsSep "\n" personNodes} } ''} subgraph cluster_phones { label="Phones"; style=filled; fillcolor="#F2F3F4"; ${lib.concatStringsSep "\n" phoneNodes} } ${lib.optionalString (cfg.sharedMailbox != null || lib.any (p: p.mailbox) (lib.attrValues cfg.persons)) '' subgraph cluster_mailboxes { label="Voicemail"; style=filled; fillcolor="#F5EEF8"; ${lib.concatStringsSep "\n" mailboxNodes} } ''} ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) '' subgraph cluster_extensions { label="Extensions"; style=filled; fillcolor="#EBF5FB"; ${lib.concatStringsSep "\n" extensionNodes} ${lib.concatStringsSep "\n" intercomNodes} } ''} // Trunk → DID ${lib.concatStringsSep "\n" trunkDidEdges} // DID routing → phones ${lib.concatStringsSep "\n" didRoutingEdges} // No-answer → mailbox ${lib.concatStringsSep "\n" didMailboxEdges} // Person → phones ${lib.concatStringsSep "\n" personPhoneEdges} // Intercom ${lib.concatStringsSep "\n" intercomEdges} // Page ${lib.concatStringsSep "\n" pageEdges} } ''; dotFile = pkgs.writeText "voip.dot" dotSource; svgFile = pkgs.runCommand "voip-diagram.svg" { nativeBuildInputs = [ pkgs.graphviz ]; } "dot -Tsvg ${dotFile} -o $out"; # HTML head — everything up to and including the diagram container opening tag htmlHead = pkgs.writeText "voip-head.html" '' ${cfg.directoryName} — VoIP

${cfg.directoryName} — VoIP Routing

Routing Diagram

''; # HTML tail — closes the diagram div and adds all info tables htmlTail = pkgs.writeText "voip-tail.html" ''
${lib.optionalString hasTrunk ''

SIP Trunks

${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: let callerIdCell = if t.callerIdFile != null || t.callerId != "" then rtv t.callerId t.callerIdFile else "provider default"; in '''' ) cfg.sipTrunks)}
NameHostUsernameTransportCaller IDCodecs
${name}${rtv t.host t.hostFile}${rtv t.username t.usernameFile}${t.transport}${callerIdCell}${lib.concatStringsSep " › " t.codecs}

DIDs (Inbound)

${lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg: let r = didCfg.routing; numCell = if didCfg.numberFile != null then "@@${didCfg.numberFile}@@" else if didCfg.number != "" then "${didCfg.number}" else "(none)"; routeStr = if r.type == "all" then "all phones (L2)" else if r.type == "person" then "→ ${r.person} (L1)" else "→ ${lib.concatStringsSep ", " r.persons} (L2)"; mohCell = if didCfg.musicOnHold != null then didCfg.musicOnHold else "ringback"; in '''' ) cfg.dids)}
IDNumberNameTrunkRoutingTimeoutMailboxMusic on Hold
${id}${numCell}${didCfg.displayName}${didCfg.trunk}${routeStr}${toString didCfg.routing.timeout}s${didCfg.mailbox}${mohCell}
''}

Persons

${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: person: let devList = lib.concatStringsSep "
" (lib.mapAttrsToList (pkey: ph: "${pkey} (${ph.model})" ) person.phones); mailboxCell = if person.mailbox then "ext. ${person.extension}" else "none"; greetingCell = if person.mailboxGreeting != null then "custom" else "default"; in '''' ) cfg.persons)}
KeyNameExtensionMailboxRing timeoutGreetingDevices
${key}${person.displayName}${person.extension}${mailboxCell}${toString person.ringTimeout}s${greetingCell}${devList}
${lib.optionalString (cfg.sharedPhones != {}) ''

Shared Phones

${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone: '''' ) cfg.sharedPhones)}
Key / MACNameExtensionModelLabel
${key}${phone.displayName}${phone.extension}${phone.model}${phone.label}
''} ${lib.optionalString (cfg.sharedMailbox != null) ''

Shared Mailbox

NameMailbox IDCheck extensionGreeting
${cfg.sharedMailbox.displayName} ${cfg.sharedMailbox.mailboxId} ${cfg.sharedMailbox.checkExtension} ${if cfg.sharedMailbox.greeting != null then "custom" else "default"}
''} ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''

Extensions

${lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: let detail = if extCfg.mode == "page" then "all phones" else extCfg.app; in '''' ) cfg.extensions)} ${lib.concatStringsSep "\n" (map (ic: '''' ) intercomEntries)}
ExtensionNameTypeDetail
${ext}${extCfg.displayName}${extCfg.mode}${detail}
${ic.extension}${ic.displayName}intercom${ic.phoneKey}
''} ${lib.optionalString (cfg.mohClasses != {}) ''

Music on Hold

${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: '''' ) cfg.mohClasses)}
ClassFilesSort
${name}${toString (lib.length cls.files)}${cls.sort}
''}

Codecs

${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: m: '''' ) models)} ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: '''' ) cfg.sipTrunks)}
Endpoint typePreference order
${name}${lib.concatStringsSep " › " m.codecs}
trunk: ${name}${lib.concatStringsSep " › " t.codecs}

Server

${lib.optionalString (cfg.intercomPrefix != null) ''''}
ParameterValue
Address${cfg.serverAddress}
SIP port${toString cfg.sipPort}
RTP range${toString cfg.rtpStart} – ${toString cfg.rtpEnd}
Directory port${toString cfg.directoryPort}
Intercom prefix${cfg.intercomPrefix}
''; # Web root directory: index.html with inlined SVG (head + svg + tail), plus raw .dot webRoot = pkgs.runCommand "voip-webroot" {} '' mkdir -p $out cat ${htmlHead} ${svgFile} ${htmlTail} > $out/index.html cp ${dotFile} $out/voip.dot ''; in { inherit webRoot svgFile dotFile; }