App-OpenHAP
view release on metacpan or search on metacpan
lib/App/OpenHAP/Host.pm view on Meta::CPAN
# ex:ts=8 sw=4:
# $OpenBSD$
#
# Copyright (c) 2026 Dick Olsson <hi@senzilla.io>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
use v5.36;
package App::OpenHAP::Host;
our $VERSION = '0.1.0';
use IO::Socket::INET;
use Time::HiRes qw(time);
use Fugu::EventLoop;
use Fugu::File;
use Fugu::Log;
use Fugu::Mdnsd;
use Protocol::HAP::Server;
use Protocol::HAP::Store::File;
# App::OpenHAP::Host - the host of the Protocol::HAP engine.
#
# The engine owns the protocol; this module owns everything the
# operating system hands out: the listening socket, per-connection
# reads and writes, the event loop, its timers, the MQTT client, and
# the mDNS advertisement. The engine reaches the outside world only
# through the contracts it was constructed with.
# How much the server reads from a client at a time.
use constant READ_SIZE => 65536;
# The interval between MQTT reconnection attempts, in seconds. A
# broker that is down stays down for a while, and a daemon that
# hammers it helps nobody.
use constant MQTT_RECONNECT_INTERVAL => 30;
sub new ( $class, %args )
{
# The other fields - the storage, the engine, the MQTT client,
# the mDNS handle, the loop and the listening socket - appear
# when their setters and accessors run.
my $self = bless {
port => $args{port} // 51827,
storage_path => $args{storage_path},
# Each connection is filed under its session id: the
# session and its socket together. A fileno index
# resolves reads to the session id. The kernel reuses
# descriptors; session ids never repeat.
connections => {},
by_fileno => {},
mqtt_tick_interval => 0.1, # MQTT poll interval in seconds
# For the uptime in a control status. Time::HiRes::time
# is imported here, and it gives a float; a whole second
# is all an uptime needs.
started => int time,
}, $class;
$self->{storage} = Protocol::HAP::Store::File->new(
path => $self->{storage_path},
logger => Fugu::Log->default,
);
# Build the engine over the host contracts: the storage, the
# process logger, writes through the connection map, and the
# one-shot timers of the loop.
$self->{engine} = Protocol::HAP::Server->new(
name => $args{name},
pin => $args{pin},
setup_id => $args{setup_id},
store => $self->{storage},
logger => Fugu::Log->default,
output => sub ( $session, $bytes ) {
$self->_write( $session, $bytes );
},
after => sub ( $seconds, $code ) {
return $self->loop->after( $seconds, $code );
},
cancel => sub ($handle) {
$self->loop->cancel($handle);
},
on_pairing_changed => sub ($paired) {
$self->_refresh_mdns($paired);
},
);
return $self;
}
# $self->engine:
# The Protocol::HAP engine of this host.
sub engine ($self)
{
return $self->{engine};
}
# --- delegation to the engine ---------------------------------------------
sub add_accessory ( $self, $accessory )
{
$self->{engine}->add_accessory($accessory);
return;
}
sub is_paired ($self)
{
return $self->{engine}->is_paired;
}
sub update_config_number ($self)
{
return $self->{engine}->update_config_number;
}
# $self->mdns_txt_string:
# The TXT records of the engine, in the format that mdnsd
# publishes.
sub mdns_txt_string ($self)
{
return Fugu::Mdnsd::format_txt(
%{ $self->{engine}->mdns_txt_records } );
}
# --- the connection plumbing ----------------------------------------------
# $self->loop:
# The event loop of this server. The server makes one on demand,
# so a caller that only wants to drive timers by hand does not
# have to build one.
sub loop ($self)
{
$self->{loop} //= Fugu::EventLoop->new;
return $self->{loop};
}
# $self->listen:
# Open the HAP listener and register it with the loop. The method
# returns the socket. It dies when the port is not available:
# a HAP server that cannot listen has no reason to run.
sub listen ($self)
{
return $self->{server} if $self->{server};
my $server = IO::Socket::INET->new(
LocalPort => $self->{port},
Type => SOCK_STREAM,
Reuse => 1,
Listen => 10,
)
or do {
Fugu::Log->default->error(
'Cannot create server socket on port %d: %s',
$self->{port}, $! );
die "Cannot create server: $!";
};
$self->{server} = $server;
$self->loop->add_fd( $server, read => sub ($) { $self->_accept } );
Fugu::Log->default->info( 'OpenHAP server listening on port %d',
$self->{port} );
return $server;
}
# $self->run:
# Serve until the loop stops. The method returns when a signal
# interrupted the loop or a callback stopped it. Thus the caller
# runs its own shutdown, and nothing has to exit from inside a
# signal handler.
sub run ($self)
{
$self->listen;
$self->_register_mqtt;
$self->loop->run;
Fugu::Log->default->info('OpenHAP server stopped');
return $self;
}
# $self->shutdown:
# Close the listener and every client connection. The caller
# calls this after run returns.
sub shutdown ($self)
{
for my $conn ( values %{ $self->{connections} } ) {
lib/App/OpenHAP/Host.pm view on Meta::CPAN
return;
}
# $self->_write($session, $bytes):
# The output contract of the engine: write the bytes to the
# connection that the session is filed under, whole. A
# controller that closes mid-write raises SIGPIPE, which would
# kill the daemon; the local guard turns it into an EPIPE that
# the checked loop reports. A connection the host cannot write
# is a connection it drops.
sub _write ( $self, $session, $bytes )
{
my $conn = $self->{connections}{ $session->id } or return;
local $SIG{PIPE} = 'IGNORE';
unless ( Fugu::File->_write_all( $conn->{socket}, $bytes, 'session' ) )
{
Fugu::Log->default->warning(
'Dropping session %d: write failed',
$session->id );
$self->_close_client( $conn->{socket} );
}
return;
}
# $self->_close_client($sock):
# Drop a client and everything the server kept for it.
sub _close_client ( $self, $sock )
{
# A write failure inside receive drops the connection before
# the read path does, so a second close must be a no-op. A
# closed handle has no fileno, and a handled one has no row.
my $fileno = fileno $sock // return;
my $sid = delete $self->{by_fileno}{$fileno} // return;
my $conn = delete $self->{connections}{$sid};
$self->{engine}->session_close( $conn->{session} );
$self->loop->remove_fd($sock);
$sock->close;
return;
}
# --- MQTT -------------------------------------------------------------------
# $self->set_mqtt_client($mqtt):
# Set the MQTT client for event loop integration
sub set_mqtt_client ( $self, $mqtt )
{
$self->{mqtt_client} = $mqtt;
return;
}
# $self->_register_mqtt:
# Put the MQTT client on the loop: a tick on every interval, and
# a reconnection attempt on its own slower schedule.
#
# The two are separate timers because they answer to different
# clocks. Before this, one poll interval drove both, and the
# backoff was an epoch comparison inside the pass.
sub _register_mqtt ($self)
{
return unless $self->{mqtt_client};
return if $self->{mqtt_timers};
$self->{mqtt_timers} = [
$self->loop->every(
$self->{mqtt_tick_interval},
sub {
my $mqtt = $self->{mqtt_client} or return;
$mqtt->tick(0) if $mqtt->is_connected;
}
),
$self->loop->every(
MQTT_RECONNECT_INTERVAL,
sub { $self->_mqtt_retry }
),
];
return;
}
# $self->_mqtt_retry:
# One reconnection attempt, if the client is down.
sub _mqtt_retry ($self)
{
my $mqtt = $self->{mqtt_client} or return;
return if $mqtt->is_connected;
unless ( $mqtt->reconnect ) {
Fugu::Log->default->debug(
'MQTT reconnection attempt failed, will retry');
return;
}
Fugu::Log->default->info('Reconnected to MQTT broker');
$self->_mqtt_resubscribe_accessories;
return;
}
# $self->_mqtt_resubscribe_accessories:
# Resubscribe all accessories to their MQTT topics
sub _mqtt_resubscribe_accessories ($self)
{
for my $acc ( $self->{engine}->get_bridged_accessories ) {
eval { $acc->subscribe_mqtt; };
Fugu::Log->default->error(
'Failed to resubscribe accessory: %s', $@ )
if $@;
}
return;
}
# --- mDNS ---------------------------------------------------------------------
# $self->set_mdns($mdns):
# Set the mDNS registration handle. The engine reports pairing
# changes through on_pairing_changed, and this host re-advertises
# the TXT record (HAP-mDNS.md §8).
sub set_mdns ( $self, $mdns )
{
$self->{mdns} = $mdns;
return;
( run in 1.724 second using v1.01-cache-2.11-cpan-ad66724bd6a )