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
- Set a timer: dial
55followed by the number of minutes, for example5510for ten minutes. You hear a tone and “Kookwekker gezet” (Dutch for “kitchen timer set”), and you can hang up. - Or let it ask: dial just
55. A voice asks you to enter the number of minutes. - Time’s up: only the phone that set the timer rings, with “Kookwekker” as caller ID. Pick up and you hear a series of beeps. If nobody answers, it tries again twice, 30 seconds apart.
- Cancel: dial
550, zero minutes. That cancels all running timers for your phone.
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:
- Name: Kookwekker
- Number to call application: 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:
- Who’s calling. The caller ID is the extension that dialed 55. That’s where the timer will ring later, and it’s part of the file name, so the timer can be cancelled per phone. Anything that isn’t a plain number (a call from outside, say) gets “invalid” and is hung up.
- How many minutes. If you dialed
55plus minutes, the minutes are already in the channel variableKW_MIN(more on that in step 3). Otherwise the script plays the prompt and waits up to five seconds for at most three digits. It tries twice before giving up. - Cancel. Zero minutes deletes every pending call file for this phone and plays two beeps.
- The call file. It rings the extension (
Channel), shows “Kookwekker” as caller ID, rings for 45 seconds (WaitTime), and tries again twice (MaxRetries) with 30 seconds in between (RetryTime). When someone answers, it plays five beeps. - Write, set the time, then move. The script first writes the file outside the
outgoingdirectory, sets its modification time to the moment the timer should go off, and only then moves it intooutgoing. That’s the recommended way: Asterisk watches that directory, and could otherwise pick up a half-written file, or one that doesn’t have its future time yet. A rename within the same file system is atomic, so Asterisk sees either nothing or the complete file. - The time is in the file name. That makes every timer unique, so you can run several at once.
- Confirmation. A beep and the confirmation prompt. Asterisk can also read out the number of minutes with
SayNumber. I tried that, but the Dutch number recordings that come with MikoPBX are of such poor quality that you can’t understand them. So$sayNumberis off.
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:
[all_peers](+)adds lines toall_peers, the context MikoPBX uses for calls from internal phones. The(+)means “append to the existing context” instead of defining a new one._55X!matches 55 followed by at least one digit. Plain 55 doesn’t match, so that still goes straight to the application.Set(KW_MIN=${EXTEN:2})takes the dialed number minus its first two characters, so5510becomes10, and stores it in the variable the script looks for.Gosub(applications,55,1)then runs the kitchen timer application, as if you had dialed 55.
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 |
One long ring, the normal ring | ||
Bellcore-dr2 |
Two shorter rings | Internal calls (part 3) | |
Bellcore-dr3 |
Short, short, long | The kitchen timer | |
Bellcore-dr4 |
Short, long, short | ||
Bellcore-dr5 |
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.
$analogin the script lists the extensions on the ATAs, 21 and 22. For those, the call file doesn’t ringPJSIP/21directly but goes through thekookwekker-analoogcontext via aLocalchannel. The other phones are called directly and ring normally.[kookwekker-analoog]dials the extension, with a ringing time of 45 seconds. Theb()option runs a subroutine on the outgoing channel just before it rings.[kookwekker-hdr]is that subroutine: it adds theAlert-Infoheader. If the variableKW_ALERTis set, it uses that pattern instead, but by default it’sBellcore-dr3.
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.