Home phone system, part 7

A kitchen timer in MikoPBX

November 02, 2026

In part 6 Home Assistant got its own extension. This last part is about a feature that doesn’t need Home Assistant at all: a kitchen timer. Pick up any phone in the house, dial 55 and the number of minutes, hang up, and when the time is up, that same phone rings. It runs entirely inside MikoPBX.

How it works for the user

You can set several timers at once, up to four hours each.

That’s also why the kitchen timer is on 55. The timer I use most is five minutes, and 555 is about the fastest thing you can type on a phone.

Why in MikoPBX?

Home Assistant already has an extension, so building the timer there would have been the obvious choice. I built it in MikoPBX for two reasons. First, I wanted to try what MikoPBX can do on its own. Second, I doubted whether I could make it work in Home Assistant without a voice prompt, with the minutes sent along in the number you dial. To me, that’s the whole point: setting a timer takes a few seconds, and you don’t have to listen to anything.

The trick: Asterisk call files

MikoPBX is built on Asterisk, and Asterisk has a little-known but very handy feature: call files. Drop a small text file into Asterisk’s outgoing spool directory, and Asterisk places the call it describes: which phone to ring, and what to do when someone answers.

The nice part: if the file’s modification time is in the future, Asterisk leaves it alone until that moment. So a kitchen timer is nothing more than a call file with a modification time ten minutes from now. No scheduler, no cron job, no extra service.

Step 1: create a dialplan application

In MikoPBX, go to Dialplan applications and add a new application:

The dialplan application Kookwekker in MikoPBX: number 55, code type PHP-AGI script

A PHP-AGI script is a PHP script that controls a call through Asterisk’s AGI (Asterisk Gateway Interface): answer, play a sound, read the keys pressed, hang up. MikoPBX ships with a PHP class for that.

Step 2: the script

Put this on the Program code tab. I translated the comments; the original ones are in Dutch.

<?php
use MikoPBX\Core\Asterisk\AGI;
require_once 'Globals.php';

// --- Settings ---
$spool     = '/storage/usbdisk1/mikopbx/astspool';
$maxMin    = 240;
$prompt    = '/storage/usbdisk1/mikopbx/media/custom/eef00aab7ee1b911cb2ccb4c168ccdebfb16f1fe_nl_-_ttsgoogle_en_com';     // path without extension to "enter the number of minutes"; empty = beep
$confirm   = '/storage/usbdisk1/mikopbx/media/custom/c9d89c69a93b1883edbc67887f76aeacd86d5499_nl_-_ttsgoogle_en_com';     // path without extension to "kitchen timer set"; empty = beep only
$sayNumber = false;   // true if the Dutch number sound files are installed
$language  = 'en-en';
$analog    = ['21','22'];     // these extensions get their own ring pattern

$agi = new AGI();
$agi->answer();

$fail = function () use ($agi) {
    $agi->exec('Playback', 'pbx-invalid');
    $agi->hangup();
    exit;
};

$caller = (string)($agi->request['agi_callerid'] ?? '');
if (!ctype_digit($caller)) {
    $fail();
}

// With the prefix (55 + minutes) KW_MIN is already set; otherwise we ask for it
$min = (string)$agi->get_variable('KW_MIN', true);
for ($try = 0; $min === '' && $try < 2; $try++) {
    $res = $agi->getData($prompt !== '' ? $prompt : 'beep', 5000, 3);
    $digits = trim((string)($res['result'] ?? '-1'));
    if ($digits === '-1') {
        exit;              // caller hung up
    }
    $min = $digits;        // empty on timeout → one more try
}
if (!ctype_digit($min)) {
    $fail();
}
$min = (int)$min;

// 0 minutes = cancel
if ($min === 0) {
    foreach (glob("$spool/outgoing/kookwekker-$caller-*.call") ?: [] as $f) {
        @unlink($f);
    }
    $agi->exec('Playback', 'beep&beep');
    $agi->hangup();
    exit;
}
if ($min > $maxMin) {
    $fail();
}

$channel = in_array($caller, $analog, true)
    ? "Local/$caller@kookwekker-analoog/n"
    : "PJSIP/$caller";

$target  = time() + $min * 60;
$tmp     = "$spool/kookwekker-$caller.tmp";
$final   = "$spool/outgoing/kookwekker-$caller-$target.call";
$content = implode("\n", [
    "Channel: $channel",
    'CallerID: "Kookwekker" <55>',
    'WaitTime: 45',
    'MaxRetries: 2',
    'RetryTime: 30',
    'Application: Playback',
    'Data: beep&beep&beep&beep&beep',
]) . "\n";

if (file_put_contents($tmp, $content) === false
    || !touch($tmp, $target)
    || !rename($tmp, $final)) {
    @unlink($tmp);
    $fail();
}

$agi->exec('Playback', 'beep');
if ($confirm !== '') {
    $agi->exec('Playback', $confirm);
}
if ($sayNumber) {
    $agi->exec('Set', "CHANNEL(language)=$language");
    $agi->verbose('Kookwekker language: ' . $agi->get_variable('CHANNEL(language)', true), 1);
    $agi->exec('SayNumber', (string)$min);
}
$agi->hangup();

How it works:

The prompts. The two spoken texts, “enter the number of minutes” and “kitchen timer set”, are ordinary sound files. I generated them with Home Assistant’s text-to-speech and uploaded them to MikoPBX as custom sound files. The script refers to them by their full path, without extension; that’s how Asterisk’s Playback expects them. The long file names are simply the names Home Assistant gave them. If you leave $prompt or $confirm empty, the script plays a beep instead.

Step 3: dialing 55 plus minutes

The dialplan application only answers on exactly 55. To dial the minutes along with it, you need a bit of plain Asterisk dialplan. MikoPBX lets you add that under System files: pick /etc/asterisk/extensions.conf and set the mode to Append to end of file. Then add this:

[all_peers](+)
exten => _55X!,1,Set(KW_MIN=${EXTEN:2})
 same => n,Gosub(applications,55,1)
 same => n,Hangup()

[kookwekker-analoog]
exten => _X!,1,Dial(PJSIP/${EXTEN},45,b(kookwekker-hdr^s^1))
 same => n,Hangup()

[kookwekker-hdr]
exten => s,1,Set(PJSIP_HEADER(add,Alert-Info)=${IF($["${KW_ALERT}" = ""]?Bellcore-dr3:${KW_ALERT})})
 same => n,Return()

The first block handles the minutes:

On the Cisco ATA, this is why the dial plan from part 3 has a separate rule for 55: 55>#t1- lets any number of digits through after 55 and sends them one second after the last digit.

Step 4: a recognizable ring on the analog phones

The other two blocks are for the analog phones. When the kitchen timer goes off on the T65, I want to hear that it’s the timer and not a call. So it rings short, short, long.

That works with the SIP header Alert-Info. MikoPBX puts it in the SIP message that rings the phone, the ATA recognizes it and plays the matching ring pattern. Bellcore-dr3 is one of a set of standard patterns, defined by Telcordia (formerly Bellcore), and dr3 is short, short, long. In part 3 I used the same trick to give internal calls their own ring.

These are the patterns, with their timings in seconds. Each bar shows one six-second cycle; the blocks are when the bell rings. Hover over a bar for the exact timings:

Alert-Info Pattern (6 s) Sounds like Used for
Bellcore-dr1 Bellcore-dr1: 2 on, 4 off (seconds) One long ring, the normal ring
Bellcore-dr2 Bellcore-dr2: 0.8 on, 0.4 off, 0.8 on, 4 off (seconds) Two shorter rings Internal calls (part 3)
Bellcore-dr3 Bellcore-dr3: 0.4 on, 0.2 off, 0.4 on, 0.2 off, 0.8 on, 4 off (seconds) Short, short, long The kitchen timer
Bellcore-dr4 Bellcore-dr4: 0.3 on, 0.2 off, 1 on, 0.2 off, 0.3 on, 4 off (seconds) Short, long, short
Bellcore-dr5 Bellcore-dr5: 0.5 on, once (seconds) A single short ring

Cisco lists bellcore-dr1 to bellcore-dr5 as the standard Telcordia distinctive ring types, but doesn’t give their timings. The timings come from the alerting patterns 1 to 5 in another Cisco document. That document doesn’t mention the Bellcore names, so matching them by number is my own inference. For dr2 and dr3 it matches what I hear on my T65.

Wrapping up

That’s the end of the series. We started with a question: how to stay reachable without a smartphone within arm’s reach. We ended up with a PBX on the NAS, a T65 and cordless phones on the same network, a real phone number, a Home Assistant that answers the phone, and a kitchen timer that’s set with three digits.

I hope you enjoyed reading along, and that it inspired you to build something like it yourself.