diff --git a/lib/HA_Item.pm b/lib/HA_Item.pm index 1a6c36e5a..e34550378 100644 --- a/lib/HA_Item.pm +++ b/lib/HA_Item.pm @@ -62,17 +62,25 @@ Description: - when the MH item is set locally, a state change is sent to HA - state is not reflected locally until the state change is received back from HA *** IMPORTANT *** : not all HA Entity types are supported. + *** IMPORTANT *** : To mimic the MH 'one object one state' approach, subtypes are used in the domain based on HA attributes + *** IMPORTANT *** : OR + *** IMPORTANT *** : You can collect multiple entities into a single MH object + *** IMPORTANT *** : - use one or more patterns to match HA entity names, separated by | + *** IMPORTANT *** : - simple patterns can include a '*' at the end -- will match a prefix. attr name + *** IMPORTANT *** : will be the suffix + *** IMPORTANT *** : or + *** IMPORTANT *** : - use a full regex -- if you bracket a portion, it will be the attr name + *** IMPORTANT *** : - eg. ecowitt_weather_(.*) will match ecowitt_weather_current_temperature and the + *** IMPORTANT *** : the attribute name will be current_temperature - light: on, off and brightness :rgb_color : for setting an RGB value - cover: open,stop,close :digital : for allowing granular setpoints - lock: lock, unlock - switch: on,off - - sensor, binary_sensor: - - can group multiple sensors into a single MH item -- populates $item->{attr} hash - - use one or more patterns to match HA entity names, separated by | - - currently only pattern supported is entity_prefix_* (text with a '*' at the end) + - number: well, a number. Since MH doesn't allow text entry through the web interface, you should set_states(x,y,z) in usercode if the webUI is used for control + - sensor, binary_sensor: usually a number value, not settable - climate: (settable subtypes) :hvac_mode # hvac mode @@ -128,6 +136,7 @@ Usage: config parms: homeassistant_address= homeassistant_api_key= + homeassistant_no_labels = 1 # disable using Friendly_Name to create web object labels .mht file: @@ -135,14 +144,14 @@ Usage: # HA_SERVER, obj name, address, keepalive, api_key HA_SERVER, ha_house, 10.3.1.20:8123, 10, XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - #HA_ITEM, object_name, domain[:subtype], ha_entity, ha_server + #HA_ITEM, object_name, domain[:subtype], ha_entity, ha_server, groups, options HA_ITEM, shed_counter_pots, light, shed_counter_pots, ha_house HA_ITEM, water, switch, house_water_socket, ha_house HA_ITEM, thermostat, climate, family_room_thermostat, ha_house HA_ITEM, ecowitt_weather, sensor, hp2551bu_pro_v1_7_6_*|ecowitt_cottage_weather_*, ha_house - HA_ITEM, led_strip, light:rgb, yeelight_012342, ha_house - + HA_ITEM, led_strip, light:rgb, yeelight_012342, ha_house, , no_duplicate_states + currently the only option is no_duplicate_states to prevent polled devices (like iot class) to update itself constantly with the current state and misterhouse user code: @@ -270,24 +279,24 @@ sub log { $str = $self->break_long_str( $str, $prefix, 300 ); &main::print_log( $str ); + return $str; } sub debug { my( $self, $level, $str ) = @_; - if( $main::Debug{ha_server} >= $level ) { - $level = 'D' if $level == 0; + if( $self->debuglevel( $level, 'ha_server' ) ) { $self->log( $str, "[HA_Server D$level]: " ); } } sub error { my ($self, $str, $level ) = @_; - &HA_Server::log( $self, $str, "[HA_Server ERROR]: " ); + $str = &HA_Server::log( $self, $str, "[HA_Server ERROR]: " ); } sub dump { my( $self, $obj, $maxdepth ) = @_; - $obj = $obj || $self; + $obj = $obj || "undef"; $maxdepth = $maxdepth || 2; my $dumper = Data::Dumper->new( [$obj] ); $dumper->Maxdepth( $maxdepth ); @@ -312,8 +321,6 @@ sub new { my ( $class, $name, $address, $keep_alive_timer, $api_key ) = @_; my $self; - print "creating HA Server $name on $address\n"; - if( !defined $main::Debug{ha_server} ) { $main::Debug{ha_server} = 0; } @@ -323,42 +330,60 @@ sub new { $keep_alive_timer = $keep_alive_timer || '10'; $keep_alive_timer += 0; - $self = {}; - - bless $self, $class; - - $$self{state} = 'off'; - $$self{said} = ''; - $$self{state_now} = 'off'; - - $self->{ip_address} = $address; - $self->{keep_alive_timer} = $keep_alive_timer; - $self->{reconnect_timer} = 10; - $self->{next_id} = 20; - $self->{subscribe_id} = 0; - $self->{api_key} = $api_key; - $self->{max_payload_size} = $::config_parms{homeassistant_max_payload_size} || 2000000; #2M default Payload size - $self->{init_v_cmd} = 0; - - $self->{next_ping} = 0; - $self->{got_ping_response} = 1; - $self->{ping_missed_count} = 0; - - $self->{recon_timer} = ::Timer::new(); - - $self->{name} = $name; - - - $self->log("Creating $name on $$self{ip_address}"); + foreach my $ha_server (values %HA_Server_List) { + if( $ha_server->{ip_address} eq $address ) { + $self = $ha_server; + } + } + if( $self ) { + $self->log( "Existing HA Server $self->{name} found for $address -- reusing" ); + $self->remove_all_items(); + @{ $self->{unhandled_entities} } = (); + if( !$self->{authenticated} ) { + $self->disconnect(); + } else { + $self->request_entity_states(); + } + } else { + $self = {}; + bless $self, $class; + $self->log( "creating HA Server $name on $address" ); + + &::MainLoop_pre_add_hook( \&HA_Server::check_for_data, 1, $self ); + # &::Reload_pre_add_hook( \&HA_Server::disconnect, 1, $self ); + &::Reload_post_add_hook( \&HA_Server::restore_entity_states, 1, $self ); + &::Reload_post_add_hook( \&HA_Server::generate_voice_commands, 1, $self ); + + $HA_Server_List{$name} = $self; + $self->{connected} = 0; + $self->{authenticated} = 0; + $self->{state} = 'off'; + $self->{said} = ''; + $self->{state_now} = 'off'; + + $self->{ip_address} = $address; + $self->{keep_alive_timer} = $keep_alive_timer; + $self->{reconnect_timer} = 10; + $self->{next_id} = 20; + $self->{subscribe_id} = 0; + $self->{api_key} = $api_key; + $self->{max_payload_size} = $::config_parms{homeassistant_max_payload_size} || 2000000; #2M default Payload size + $self->{init_v_cmd} = 0; + $self->{recon_timer} = ::Timer::new(); + + $self->{next_ping} = 0; + $self->{got_ping_response} = 1; + $self->{ping_missed_count} = 0; - $HA_Server_List{$self->{name}} = $self; + @{ $self->{unhandled_entities} } = (); + @{ $self->{objects} } = (); + } - &::MainLoop_pre_add_hook( \&HA_Server::check_for_data, 1, $self ); - &::Reload_post_add_hook( \&HA_Server::restore_entity_states, 1, $self ); - &::Reload_post_add_hook( \&HA_Server::generate_voice_commands, 1, $self ); + $self->{name} = $name; $self->connect(); + return $self; } @@ -366,7 +391,18 @@ sub new { sub connect { my ($self) = @_; - $self->{socket_item} = new Socket_Item( undef, undef, $self->{ip_address}, $self->{name}, 'tcp', 'raw' ); + if( $self->{connected} ) { + return; + } + + if( !$self->{socket_item} ) { + $self->{socket_item} = new Socket_Item( undef, undef, $self->{ip_address}, $self->{name}, 'tcp', 'raw' ); + } + + if( $self->{socket_item}->connected() ) { + $self->error( "connect called on $self->{name} when socket already connected" ); + return; + } if( !$self->{socket_item}->start() ) { $self->log( "Unable to connect socket to $self->{ip_address} ... trying again in $self->{reconnect_timer}s" ); @@ -375,7 +411,7 @@ sub connect { } return; } else { - $self->log( "Connected to HomeAssistant server at $self->{ip_address}" ); + $self->log( "$self->{name} connected to HomeAssistant server at $self->{ip_address}" ); } my $ws_client = Protocol::WebSocket::Client->new(url => 'ws://' . $self->{ip_address} . '/api/websocket' ); @@ -412,21 +448,51 @@ sub connect { $ws_client->{frame_buffer}->{max_payload_size} = $self->{max_payload_size}; $self->{ws_client}->connect(); + $self->{connected} = 1; +} + +=item C + + Disconnect the websocket connection from an HA_Server object to the Home Assistant server. + +=cut + +sub disconnect { + my ($self) = @_; + + if( !$self->{socket_item} || !$self->{socket_item}->active() ) { + $self->log( "$self->{name} has disconnected -- cleaning up" ); + } else { + $self->log( "$self->{name} disconnecting and cleaning up" ); + if( $self->{ws_client} ) { + $self->{ws_client}->disconnect(); + } + $self->{socket_item}->stop(); + } + delete $self->{ws_client}; + $self->{ws_client} = undef; + $self->{connected} = 0; + $self->{authenticated} = 0; } + sub check_for_data { my ($self) = @_; my $ha_data; if( $self->{socket_item} ) { - if( $self->{socket_item}->active_now() ) { - $self->debug( 1, "Homeassistant server started" ); - } - if( $self->{socket_item}->inactive_now() ) { - $self->debug( 1, "Homeassistant server close" ); - $self->disconnect(); - $self->connect(); - next; + if( $self->{socket_item}->active_now() && $self->{socket_item}->inactive_now() ) { + $self->log( "server '$self->{name}' bounced -- probably a reload -- ignoring" ); + } elsif( $self->{socket_item}->active_now() ) { + $self->log( "server '$self->{name}' started" ); + } elsif( $self->{socket_item}->inactive_now() ) { + # On reload, this state comes after the fact + $self->log( "'$self->{name}' socket closed -- reconnecting in 5 seconds" ); + $self->disconnect(); + if ($self->{recon_timer}->inactive) { + $self->{recon_timer}->set($self->{reconnect_timer}, sub { &HA_Server::connect( $self ) }); + } + return; } } @@ -441,11 +507,16 @@ sub check_for_data { } } - if( &::new_second($self->{keep_alive_time}) and $self->{ws_client} ) { + if( &::new_second($self->{keep_alive_time}) + and $self->{authenticated} + and $self->{socket_item} and $self->{socket_item}->active() + and $self->{ws_client} + ) { $self->{ws_client}->write( '{"id":' . ++$self->{next_id} . ', "type":"ping"}' ); } } + sub ha_process_write { my ($self, $data) = @_; @@ -453,6 +524,7 @@ sub ha_process_write { $data = encode_json( $data ); } if( !$self->{socket_item}->active() ) { + $self->error( "$self->{name} doing write, but socket is disconnected" ); return; } $self->debug( 1, "sending data to ha: $data" ); @@ -470,9 +542,7 @@ sub ha_process_read { $json_text = encode( "UTF-8", $data ); eval {$data_obj = JSON->new->utf8->decode( $json_text )}; if( $@ ) { - $self->error( "parsing json from homeassistant: $@ [$json_text]" ); - print "Error parsing json from homeassistant: $@\n"; - print " [$json_text]\n"; + $self->error( "parsing json from homeassistant: $@ \n JSON text: [$json_text]" ); return; } if( !$data_obj ) { @@ -482,8 +552,7 @@ sub ha_process_read { if( $data_obj->{type} eq 'pong' ) { $self->debug( 3, "Received pong from HA" ); return; - } - if( $data_obj->{type} eq 'event' && $data_obj->{id} == $self->{subscribe_id} ) { + } elsif( $data_obj->{type} eq 'event' && $data_obj->{id} == $self->{subscribe_id} ) { $self->parse_data_to_obj( $data_obj->{event}->{data}->{new_state}, "ha_server" ); return; } elsif( $data_obj->{type} eq 'auth_required' ) { @@ -492,30 +561,57 @@ sub ha_process_read { return; } elsif( $data_obj->{type} eq 'auth_ok' ) { my $subscribe; - $self->log( "Authenticated to HomeAssistant server" ); + $self->{authenticated} = 1; + $self->log( "$self->{name} authenticated to HomeAssistant server" ); $self->{subscribe_id} = ++$self->{next_id}; $subscribe->{id} = $self->{subscribe_id}; $subscribe->{type} = 'subscribe_events'; $subscribe->{event_type} = 'state_changed'; $self->ha_process_write( $subscribe ); - my $getstates; - $self->{getstates_id} = ++$self->{next_id}; - $getstates->{id} = $self->{getstates_id}; - $getstates->{type} = 'get_states'; - $self->ha_process_write( $getstates ); + $self->request_entity_states(); return; } elsif( $data_obj->{type} eq 'auth_invalid' ) { + $self->{authenticated} = 0; $self->error( "Authentication invalid: " . $self->dump($data_obj) ); - } elsif( $data_obj->{type} eq 'result' ) { + $self->disconnect(); + return; + } elsif( $data_obj->{type} eq 'result' && $data_obj->{id} == $self->{getstates_id} ) { if( $data_obj->{success} ) { - $self->debug( 1, "Received success on request $data_obj->{id}" ); - if( $data_obj->{id} == $self->{getstates_id} ) { - $self->process_entity_states( $data_obj ); - } - return; + $self->debug( 1, "Received success on getstates request $data_obj->{id}" ); + $self->process_entity_states( $data_obj ); } else { - $self->error( "Received FAILURE on request $data_obj->{id}: " . $self->dump( $data_obj ) ); + $self->error( "Received FAILURE on getstates request $data_obj->{id}: " . $self->dump( $data_obj ) ); } + return; + } elsif( $data_obj->{type} eq 'result' ) { + $self->process_result( $data_obj ); + return; + } +} + +sub set_object_state { + my ( $self, $obj, $cmd, $p_setby ) = @_; + + if( $obj->debuglevel( 3, 'ha_server' ) ) { + $obj->debug( 3, "handled event for $obj->{object_name} set by $p_setby to: ". $obj->dump($cmd, 3) ); + } else { + $obj->debug( 2, "handled event for $obj->{object_name} set by $p_setby to: $cmd->{state}" ); + } + $obj->set( $cmd, $p_setby ); + if( $p_setby eq "ha_server_init" ) { + $obj->{ha_init} = 1; + my $no_label = 0; + if (defined $::config_parms{homeassistant_no_labels}) { + $no_label = $::config_parms{homeassistant_no_labels}; + } + if (defined ( $obj->{ha_state}->{attributes}->{friendly_name}) and ($no_label == 0)) { + my $subtype = $obj->{subtype}; + $subtype =~ tr/_/ /; + $subtype = "" if (lc $subtype eq "digital"); + my $label = $obj->{ha_state}->{attributes}->{friendly_name}; + $label .= " " . $subtype if ($subtype); + $obj->set_label($label); + } } } @@ -523,29 +619,44 @@ sub parse_data_to_obj { my ( $self, $cmd, $p_setby ) = @_; my $handled = 0; - $self->debug( 2, "Msg object: " . $self->dump( $cmd, 3 ) ); + if( $self->debuglevel( 3, 'ha_server' ) ) { + $self->debug( 3, "Msg object: " . $self->dump( $cmd, 3 ) ); + } else { + $self->debug( 2, "Msg object: entity_id: $cmd->{entity_id} state: $cmd->{state}" ); + } my ($cmd_domain,$cmd_entity) = split( '\.', $cmd->{entity_id} ); for my $obj ( @{ $self->{objects} } ) { - if( $obj->{entity_prefixes} ) { - for my $prefix (@{$obj->{entity_prefixes}}) { - if( $prefix eq substr($cmd_entity,0,length($prefix)) ) { - my $attr_name = substr($cmd_entity,length($prefix)); - $obj->{attr}->{$attr_name} = $cmd->{state}; - $self->debug( 1, "handled event for $obj->{object_name} -- attr $attr_name set to $cmd->{state}" ); - # $obj->set( 'toggle', undef ); + if( $cmd->{entity_id} eq $obj->{entity_id} ) { + $self->set_object_state( $obj, $cmd, $p_setby ); + $handled = 1; + } else { + for my $pattern (@{$obj->{entity_patterns}}) { + my ($attr_name) = $cmd_entity =~ m/^$pattern$/; + $attr_name = 0 unless (defined $attr_name); + if( $attr_name eq 1 ) { + $attr_name = $cmd_entity; + } + if( $attr_name ) { + # $obj->{attr}->{$attr_name} = $cmd->{state}; + # $self->debug( 1, "handled event for $obj->{object_name} -- attr $attr_name set to $cmd->{state}" ); if( $p_setby eq "ha_server_init" ) { $obj->{ha_init} = 1; } - $handled = 1; + if( !$obj->{subitems}->{$attr_name} ) { + $obj->debug( 1, "creating subitem '${cmd_domain}.${cmd_entity}'" ); + my $subitem = new HA_Item( $cmd_domain, $cmd_entity, $obj->{ha_server}, '' ); + &main::register_object_by_name('$' . $attr_name,$subitem); + $subitem->{category} = "Dynamic"; + $subitem->{filename} = "HA_Item"; + $subitem->{object_name} = '$' . $attr_name; + $subitem->set_parent( $obj ); + $obj->{subitems}->{$attr_name} = $subitem; + $self->set_object_state( $obj->{subitems}->{$attr_name}, $cmd, $p_setby ); + $handled = 1; + } } } - } elsif( $cmd->{entity_id} eq $obj->{entity_id} ) { - $obj->set( $cmd, $p_setby ); - if( $p_setby eq "ha_server_init" ) { - $obj->{ha_init} = 1; - } - $handled = 1; } } if( !$handled ) { @@ -554,13 +665,43 @@ sub parse_data_to_obj { return $handled; } +sub process_result { + my ( $self, $result ) = @_; + my $handled = 0; + + if( $result->{success} ) { + $self->debug( 1, "Received success on request $result->{id}" ); + } else { + $self->error( "Received FAILURE on request $result->{id}: " . $self->dump( $result ) ); + } + + for my $obj ( @{ $self->{objects} } ) { + if( $obj->{msg_trk}->{pending_msgid} eq $result->{id} ) { + if( !$obj->{msg_trk}->{serialize_on_state_change} && !$obj->{msg_trk}->{serialize_on_delay} ) { + $obj->ha_send_message(); + } + } + } +} + +sub request_entity_states { + my ( $self ) = @_; + my $getstates; + + $self->{getstates_id} = ++$self->{next_id}; + $getstates->{id} = $self->{getstates_id}; + $getstates->{type} = 'get_states'; + $self->ha_process_write( $getstates ); +} + sub process_entity_states { my ( $self, $cmd ) = @_; - # print "Entity states response: \n" . $self->dump( $cmd ); + $self->debug( 1, "Processing response for get entity states" ); + $self->debug( 3, "Entity states response: \n" . $self->dump( $cmd ) ); foreach my $state_obj (@{$cmd->{result}}) { if( !$self->parse_data_to_obj( $state_obj, "ha_server_init" ) ) { - push @{ $$self{unhandled_entities} }, $state_obj->{entity_id}; + push @{ $self->{unhandled_entities} }, $state_obj->{entity_id}; } } # check that all ha_item objects had an initial state @@ -589,7 +730,7 @@ sub generate_voice_commands { my $object_string; my $object_name = $self->get_object_name; $self->{init_v_cmd} = 1; - &main::print_log("Generating Voice commands for HA Server $object_name"); + $self->log( "Generating Voice commands for HA Server $object_name" ); my $voice_cmds = $self->get_voice_cmds(); my $i = 1; @@ -624,7 +765,7 @@ sub get_voice_cmds { my ($self) = @_; my $command = $self->get_object_name; $command =~ s/^\$//; - $command =~ tr/_/ /; ## underscores in Voice_cmds cause them not to work. + $command =~ tr/_/-/; ## underscores in Voice_cmds cause them not to work. my $objects = "["; my %seen; @@ -635,12 +776,12 @@ sub get_voice_cmds { chop $objects if (length($objects) > 1); $objects .= "]"; $objects =~ s/\$//g; - $objects =~ tr/_/ /; ## underscores in Voice_cmds cause them not to work. + $objects =~ tr/_/-/; ## underscores in Voice_cmds cause them not to work. #a bit of a kludge to pass along the voice command option, get the said value from the voice command. my %voice_cmds = ( 'List [all,active,inactive] ' . $command . ' objects to the print log' => $self->get_object_name . '->print_object_list(SAID)', - 'Print ' . $objects. ' ' . $command . ' attributes to the print log' => $self->get_object_name . '->print_object_attrs(SAID)', + 'Print ' . $objects. ' on ' . $command . ' attributes to the print log' => $self->get_object_name . '->print_object_attrs(SAID)', ); return \%voice_cmds; @@ -649,66 +790,55 @@ sub get_voice_cmds { sub print_object_list { my ($self,$cmd) = @_; - main::print_log("[HA_Server]: Showing $cmd entities known by $self->{name}"); + + $self->log( "Showing $cmd entities known by $self->{name}" ); my @active_entities = (); my @inactive_entities = (); - #should be replaced with just this instance. - foreach my $ha_server ( values %HA_Server_List ) { - my %seen; - for my $obj ( @{ $ha_server->{objects} } ) { - next if $seen{$obj->{entity_id}}++; #remove duplicate entity names - push (@active_entities, $obj->{entity_id}); - } + my %seen; + for my $obj ( @{ $self->{objects} } ) { + next if $seen{$obj->{entity_id}}++; #remove duplicate entity names + push (@active_entities, $obj->{entity_id}); } @inactive_entities = @{$self->{unhandled_entities}}; if ($cmd eq 'active' or $cmd eq 'all') { for my $i (@active_entities) { - main::print_log("[HA_Server]: Active: $i"); + $self->log( "Active: $i"); } } if ($cmd eq 'inactive' or $cmd eq 'all') { for my $i (@inactive_entities) { - main::print_log("[HA_Server]: Inactive: $i"); + $self->log( "Inactive: $i"); } } } sub print_object_attrs { my ($self,$obj) = @_; - $obj =~ tr/ /_/; - main::print_log("[HA_Server]: Showing details for object $obj"); - main::print_log("[HA_Server]: -----------------------------"); - my $object = main::get_object_by_name($obj); - main::print_log("[HA_Server]: Entity = " . $object->{ha_state}->{entity_id}) if ( $object->{ha_state}->{entity_id}); - main::print_log("[HA_Server]: Subtype = " . $object->{subtype}) if ( $object->{subtype}); - main::print_log("[HA_Server]: Showing attribute raw data:"); - print Dumper $object->{ha_state}->{attributes}; + $obj =~ tr/-/_/; + $self->log( "Showing details for object $obj" ); + $self->log( "-----------------------------"); + my $object = main::get_object_by_name($obj); + $self->log( "Entity = " . $object->{entity_id}) if ( $object->{entity_id}); + $self->log( "Subtype = " . $object->{subtype}) if ( $object->{subtype}); + if( $object->{ha_state} && $object->{ha_state}->{attributes} ) { + $self->log( "Showing HA entity attributes: \n" . $self->dump( $object->{ha_state}->{attributes}) ); + } + if( $object->{subitems} ) { + # $self->log( "Showing collected entity values in attr: \n" . $self->dump( $object->{attr} ) ); + my $str=''; + for my $attr_name ( sort keys %{$object->{subitems}} ) { + $str .= " $attr_name: " . $object->{subitems}->{$attr_name}->state() . "\n"; + } + $self->log( "Showing sub-item values: \n$str" ); + } } -=item C - - Disconnect the websocket connection from an HA_Server object to the Home Assistant server. - -=cut - -sub disconnect { - my ($self) = @_; - - if( $self->{ws_client} ) { - $self->{ws_client}->disconnect(); - delete $self->{ws_client}; - } - if( $self->{socket_item} && $self->{socket_item}->active() ) { - $self->{socket_item}->stop(); - } -} - sub add { my ( $self, @p_objects ) = @_; @@ -732,7 +862,7 @@ sub add { sub add_item { my ( $self, $p_object ) = @_; - push @{ $$self{objects} }, $p_object; + push @{ $self->{objects} }, $p_object; return $p_object; } @@ -747,8 +877,8 @@ sub remove_all_items { sub add_item_if_not_present { my ( $self, $p_object ) = @_; - if ( ref $$self{objects} ) { - foreach ( @{ $$self{objects} } ) { + if ( ref $self->{objects} ) { + foreach ( @{ $self->{objects} } ) { if ( $_ eq $p_object ) { return 0; } @@ -761,10 +891,10 @@ sub add_item_if_not_present { sub remove_item { my ( $self, $p_object ) = @_; - if ( ref $$self{objects} ) { - for ( my $i = 0; $i < scalar( @{ $$self{objects} } ); $i++ ) { - if ( $$self{objects}->[$i] eq $p_object ) { - splice @{ $$self{objects} }, $i, 1; + if ( ref $self->{objects} ) { + for ( my $i = 0; $i < scalar( @{ $self->{objects} } ); $i++ ) { + if ( $self->{objects}->[$i] eq $p_object ) { + splice @{ $self->{objects} }, $i, 1; return 1; } } @@ -784,6 +914,32 @@ use JSON qw( decode_json encode_json ); use Data::Dumper; +sub log { + my( $self, $str ) = @_; + $self->{ha_server}->log( $str, "[HA_Item]:" ); +} + +sub error { + my( $self, $str ) = @_; + $self->{ha_server}->error( $str ); +} + +sub debug { + my( $self, $level, $str ) = @_; + if( $self->debuglevel( $level, 'ha_server' ) ) { + $self->{ha_server}->log( $str, "[HA_Item D$level]: " ); + } +} + +sub dump { + my( $self, $obj, $maxdepth ) = @_; + $obj = $obj || "undef"; + $maxdepth = $maxdepth || 2; + my $dumper = Data::Dumper->new( [$obj] ); + $dumper->Maxdepth( $maxdepth ); + return $dumper->Dump(); +} + =item C Creates a HA_Item object that mirrors domain.entity in the HomeAssistant server ha_server. @@ -795,12 +951,12 @@ use Data::Dumper; =cut sub new { - my ($class, $fulldomain, $entity, $ha_server ) = @_; + my ($class, $fulldomain, $entity, $ha_server, $options ) = @_; my $self = new Generic_Item(); bless $self, $class; if( !$ha_server ) { - $self->error( "No homeassistant server set" ); + &HA_Server::error( undef, "No homeassistant server set" ); return; } $self->{ha_server} = $ha_server; @@ -808,8 +964,36 @@ sub new { $subtype = "" unless $subtype; $self->{domain} = $domain; $self->{subtype} = $subtype; - $self->debug( 1, "New HA_Item ( $class, $domain, $entity, $subtype )" ); - + $self->{duplicate_states} = 1; + $self->{unavailable_count} = 0; + $self->{msg_trk} = {}; + $self->{msg_trk}->{msg_check_delay} = 5; + @{$self->{msg_trk}->{pending_msg_queue}} = (); + $self->{msg_trk}->{pending_msgid} = 0; + $self->{msg_trk}->{serialize_on_state_change} = 0; + $self->{msg_trk}->{serialize_on_delay} = 0; + + if (defined $options) { + my @option_list = split( '\|', $options ); + foreach my $option (@option_list) { + if( $option eq 'no_duplicate_states' ) { + $self->{duplicate_states} = 0; + } elsif( $option eq 'serialize_on_state_change' ) { + $self->{msg_trk}->{serialize_on_state_change} = 1; + } elsif( my ($delay) = $option =~ m/serialize_on_delay\s*\=\s*(\d+)/ ) { + $self->{msg_trk}->{msg_check_delay} = $delay; + $self->{msg_trk}->{serialize_on_delay} = 1; + } else { + $self->error( "Invalid HA_Item option: '$option'. HA_Item entity $entity NOT created" ); + return; + } + } + $self->debug( 1, "New HA_Item ( $class, $domain, $entity, $subtype, [$options] )" ); + } else { + $self->debug( 1, "New HA_Item ( $class, $domain, $entity, $subtype, [no options] )" ); + } + # $self->{attr} = {}; + $self->{subitems} = {}; if( $domain eq 'switch' ) { $self->set_states( "off", "on" ); } elsif( $domain eq 'light' ) { @@ -828,30 +1012,45 @@ sub new { $self->set_states( "unlocked", "locked" ); } elsif( $domain eq 'climate' ) { } elsif( $domain eq 'sensor' || $domain eq 'binary_sensor' ) { - $self->{attr} = {}; - } elsif( $domain eq 'select' ) { + } elsif( $domain eq 'select' || $domain eq 'input_select' ) { + } elsif( $domain eq 'number' || $domain eq 'input_number' ) { + } else { $self->error( "Invalid type for HA_Item -- '$domain'" ); return; } - my @prefixes = split( '\|', $entity ); - if( $#prefixes || substr( $entity, length($entity)-1, 1 ) eq '*' ) { - if( $#prefixes == 0 ) { - @prefixes = ($entity); - } - for my $prefix (@prefixes) { - if( substr( $prefix, length($prefix)-1, 1 ) eq '*' ) { - $prefix = substr( $prefix, 0, length($prefix)-1 ); - } - push @{$self->{entity_prefixes}}, $prefix; - } - $self->debug( 1, "${domain}.${entity} prefixes: " . join( '|', @{$self->{entity_prefixes}}) ); + my @patterns = split( '\|', $entity ); + if( scalar @patterns == 0 ) { + @patterns = $entity; + } + my $entity_name = undef; + for my $pattern (@patterns) { + if( $pattern !~ m/\*/ && !$entity_name) { + $entity_name = $pattern; + next; + } elsif( $pattern =~ m/^[^\*]*[^\.]\*$/ ) { + $pattern = substr( $pattern, 0, length($pattern)-1 ) . '(.*)'; + } + my $regex = eval { qr/$pattern/ }; + if( $@ ) { + $self->error( "invalid pattern $pattern: $@ -- Item not created" ); + return; + } + push @{$self->{entity_patterns}}, $pattern; } + if( !$entity_name ) { + $entity_name = $self->{entity_patterns}[0]; + } + + $self->{entity} = $entity_name; + $self->{entity_id} = "${domain}.${entity_name}"; - $self->{entity} = $entity; - $self->{entity_id} = "${domain}.${entity}"; + if( $self->{entity_patterns} ) { + $self->debug( 1, "$self->{entity_id} patterns: " . join( '|', @{$self->{entity_patterns}}) ); + } + $self->{msg_trk}->{msg_timer} = ::Timer::new(); $self->{ha_server}->add( $self ); $self->restore_data( 'ha_states' ); @@ -859,30 +1058,11 @@ sub new { return $self; } -sub log { - my( $self, $str ) = @_; - $self->{ha_server}->log( $str, "[HA_Item]:" ); -} - -sub error { - my( $self, $str ) = @_; - $self->{ha_server}->error( $str ); -} - -sub debug { - my( $self, $level, $str ) = @_; - if( $self->debuglevel( $level, 'ha_server' ) ) { - $self->{ha_server}->log( $str, "[HA_Item D$level]: " ); - } -} +sub set_parent { + my( $self, $parent_item ) = @_; -sub dump { - my( $self, $obj, $maxdepth ) = @_; - $obj = $obj || $self; - $maxdepth = $maxdepth || 2; - my $dumper = Data::Dumper->new( [$obj] ); - $dumper->Maxdepth( $maxdepth ); - return $dumper->Dump(); + $self->{parent_item} = $parent_item; + $self->{msg_trk} = $parent_item->{msg_trk}; } @@ -909,17 +1089,41 @@ sub set_object_debug { sub set { my ( $self, $setval, $p_setby, $p_response ) = @_; - if( $p_setby =~ /ha_server*/ ) { + if( $p_setby && $p_setby =~ /ha_server*/ ) { # This is home assistant sending a state change via websocket # This state change may or may not have been initiated by us # This is sent as an object representing the json new_state - $self->debug( 2, "$self->{object_name} set by $p_setby to: ". $self->dump($setval, 3) ); my $new_state = $setval; $self->{ha_state} = $setval; + if( ref $self->{ha_pending_setparms} ) { + $p_setby = $self->{ha_pending_setparms}->{mh_pending_setby}; + $p_response = $self->{ha_pending_setparms}->{mh_pending_response}; + } + delete $self->{ha_pending_setparms}; + if( $self->{msg_trk}->{serialize_on_state_change} && !$self->{msg_trk}->{serialize_on_delay} ) { + $self->ha_send_message(); + } + + if( $new_state->{state} eq 'unavailable' ) { + $self->debug( 1, "received 'unavailable' value for $self->{object_name}" ); + $self->{unavailable_count} += 1; + if( $self->{unavailable_count} < 3 ) { + return; + } + } else { + $self->{unavailable_count} = 0; + } + + if( ($self->{duplicate_states} == 0) and ( lc( $self->state() ) eq lc( $new_state->{state} )) ) { + $self->debug( 2, "Duplicate state $new_state->{state} ignored on $self->{object_name}" ); + return; + } + if( $self->{domain} eq 'switch' || $self->{domain} eq 'lock' || $self->{domain} eq 'sensor' + || $self->{domain} eq 'number' || $self->{domain} eq 'input_number' || $self->{domain} eq 'binary_sensor' ) { $self->debug( 1, "$self->{domain} event for $self->{object_name} set to $new_state->{state}" ); @@ -936,7 +1140,7 @@ sub set { $level = "closed" if ($level eq "0%"); $self->debug( 1, "cover:$self->{subtype} event for $self->{object_name} set to $level" ); $self->SUPER::set( $level, $p_setby, $p_response ); - } elsif( $self->{domain} eq 'select' ) { + } elsif( $self->{domain} eq 'select' || $self->{domain} eq 'input_select' ) { $self->debug( 1, "$self->{domain} event for $self->{object_name} set to $new_state->{state}" ); $self->SUPER::set( $new_state->{state}, $p_setby, $p_response ); if( $p_setby eq 'ha_server_init' ) { @@ -956,7 +1160,7 @@ sub set { my $level = $new_state->{state}; if( $new_state->{state} eq 'on' ){ if( $new_state->{attributes}->{brightness} ) { - $level = $new_state->{attributes}->{brightness} * 100 / 255; + $level = int( $new_state->{attributes}->{brightness} * 100 / 255 + .5); } } $self->debug( 1, "light event for $self->{object_name} set to $level" ); @@ -965,11 +1169,16 @@ sub set { } elsif( $self->{domain} eq 'climate' ) { my $state; foreach my $attrname (keys %{$new_state->{attributes}} ) { - # $self->{attr}->{$attrname} = $new_state->{attributes}->{$attrname}; if( $self->{subtype} eq $attrname ) { $state = $new_state->{attributes}->{$attrname}; } } + #Check for duplicates again as the $new state is inside the attributes + if( ($self->{duplicate_states} == 0) and ( lc( $self->state() ) eq lc( $new_state->{state} )) ) { + $self->debug( 2, "Duplicate climate state $new_state->{state} ignored on $self->{object_name}" ); + return; + } + if( !$state && (!$self->{subtype} || $self->{subtype} eq 'hvac_mode' ) ) { $state = $new_state->{state}; } @@ -977,7 +1186,6 @@ sub set { $self->error( "climate state message did not contain state for $self->{object_name}" ); return; } - # $self->debug( 1, "climate attributes set: " . $self->dump($self->{attr}) ); if( $self->{subtype} ) { $self->debug( 1, "climate $self->{object_name} set: $state" ); } else { @@ -998,7 +1206,12 @@ sub set { # Item has been set locally -- use HA WebSocket to change state $self->debug( 2, "$self->{object_name} set by $p_setby to: $setval" ); - if( $self->{domain} eq 'select' ) { + my $setparms = {}; + $setparms->{mh_pending_setby} = $p_setby; + $setparms->{mh_pending_response} = $p_response; + $setparms->{value} = $setval; + $self->{ha_current_setparms} = $setparms; + if( $self->{domain} eq 'select' || $self->{domain} eq 'input_select' ) { $self->ha_set_select( $setval ); } elsif( $self->{domain} eq 'climate' ) { $self->ha_set_climate( $setval ); @@ -1028,21 +1241,67 @@ sub restore_states_string { sub ha_call_service { my ($self, $service, $service_data) = @_; my $ha_msg = {}; + my $entity_id; + my $service_name; + my $domain; - $ha_msg->{id} = ++$self->{ha_server}->{next_id}; $ha_msg->{type} = 'call_service'; - $ha_msg->{domain} = $self->{domain}; $ha_msg->{target} = {}; - $ha_msg->{target}->{entity_id} = $self->{entity_id}; - $ha_msg->{service} = $service; + ($domain, $entity_id, $service_name) = $service =~ /^([^\.]+)\.([^\.]+)\.([^\.]+)$/; + if( $entity_id ) { + $entity_id = "$domain.$entity_id"; + } else { + $entity_id = $self->{entity_id}; + $service_name = $service; + $domain = $self->{domain}; + } + $ha_msg->{domain} = $domain; + $ha_msg->{target}->{entity_id} = $entity_id; + $ha_msg->{service} = $service_name; if( defined( $service_data ) && keys %$service_data) { $ha_msg->{service_data} = $service_data; } + $self->ha_send_message( $ha_msg ); +} + +sub ha_send_message { + my ($self, $ha_msg) = @_; + + if( $ha_msg ) { + $ha_msg->{ha_setparms} = $self->{ha_current_setparms}; + $self->{ha_current_setparms} = undef; + push @{$self->{msg_trk}->{pending_msg_queue}}, $ha_msg; + if( $self->{msg_trk}->{pending_msgid} ) { + $self->debug( 1, "message to HA queued" ); + return; + } + } else { + $self->{msg_trk}->{pending_msgid} = 0; + } + $ha_msg = shift @{$self->{msg_trk}->{pending_msg_queue}}; + if( !$ha_msg ) { + return; + } + $ha_msg->{id} = ++$self->{ha_server}->{next_id}; + $self->{msg_trk}->{pending_msgid} = $ha_msg->{id}; + $self->{ha_pending_setparms} = $ha_msg->{ha_setparms}; + delete $ha_msg->{ha_setparms}; + $self->debug( 2, "sending command to HA: " . $self->dump( $ha_msg ) ); + $self->{msg_trk}->{msg_timer}->stop(); + $self->{msg_trk}->{msg_timer}->set($self->{msg_trk}->{msg_check_delay}, sub { &HA_Item::ha_check_message( $self, $ha_msg ) }); $self->{ha_server}->ha_process_write( $ha_msg ); } +sub ha_check_message { + my ($self, $ha_msg) = @_; + if( $self->{msg_trk}->{pending_msgid} == $ha_msg->{id} ) { + $self->debug( 1, "$self->{object_name} message $ha_msg->{id} for entity '$ha_msg->{target}->{entity_id}' -- timer expired, sending next message" ); + $self->ha_send_message(); + } +} + sub ha_set_select { my ($self, $mode) = @_; my $cmd; @@ -1099,6 +1358,9 @@ sub ha_set_state { } elsif (lc $self->{domain} eq 'cover') { $service = 'set_cover_position'; $service_data->{position} = $numval; + } elsif ( lc $self->{domain} eq 'number' || lc $self->{domain} eq 'input_number' ) { + $service = 'set_value'; + $service_data->{value} = $numval; } } elsif( lc $mode eq 'on' ) { $service = 'turn_on'; @@ -1158,7 +1420,39 @@ sub get_rgb { } } -=item C +=item C + +Returns the state of an attribute inside a mulit-entity object + +=cut + +sub get_attr { + my ($self,$attr) = @_; + + if( !$self->{subitems}->{$attr} ) { + $self->error("get_attr called on non-existant attribute [$attr]" ); + return; + } + return $self->{subitems}->{$attr}->state(); +} + +=item C + +Returns the state of an attribute inside a mulit-entity object + +=cut + +sub set_attr { + my ( $self, $attr, $setval, $p_setby, $p_response ) = @_; + + if( !$self->{subitems}->{$attr} ) { + $self->error("set_attr called on non-existant attribute [$attr]" ); + return; + } + $self->{subitems}->{$attr}->set( $setval, $p_setby, $p_response ); +} + +=item C Override the default behaviour of the current state being unselectable in IA7 diff --git a/lib/read_table_A.pl b/lib/read_table_A.pl index eec9aa144..0bc5102c0 100644 --- a/lib/read_table_A.pl +++ b/lib/read_table_A.pl @@ -1990,9 +1990,11 @@ sub read_table_A { $code .= "\$${object_name} = new HA_Server( '$object_name', '$address', '$keepalive', '$api_key' );\n"; } elsif( $type eq "HA_ITEM" ) { - my ($object_name, $domain, $entity, $ha_server) = @item_info; + my ($object_name, $domain, $entity, $ha_server, $group, $options) = @item_info; require HA_Item; - $code .= "\$${object_name} = new HA_Item( '$domain', '$entity', \$$ha_server );\n"; + $code .= "\$${object_name} = new HA_Item( '$domain', '$entity', \$$ha_server "; + $code .= ",'$options' " if ($options); + $code .= ");\n"; } #-------------- End Home Assistant Objects ----------------- elsif ( $type =~ /PLCBUS_.*/ ) {