############################################################# # # Copyright notice # # (c) 2016 # Copyright: Juergen Kellerer (juergen at k123 dot eu) # FHEM Maintenance: Marko Oldenburg (leongaultier at gmail dot com) # All rights reserved # # This script free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # The GNU General Public License can be found at # http://www.gnu.org/copyleft/gpl.html. # A copy is found in the textfile GPL.txt and important notices to the license # from the author is found in LICENSE.txt distributed with these scripts. # # This script is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # This copyright notice MUST APPEAR in all copies of the script! # # $Id: 98_SmarterCoffee.pm 17728 2018-11-11 11:01:55Z CoolTux $ # ############################################################# # # SmaterCoffee.pm by Juergen Kellerer, 2016 # # FHEM module to communicate with a Smarter Coffee machine # # Credits: # Thanks to creators and contributors of: # - https://github.com/nanab/smartercoffee # - https://github.com/petermajor/SmartThingsSmarterCoffee # - https://github.com/Tristan79/iBrew # .. and to all the volonteers crafting the FHEM project. # # Version: 1.0.0 # ############################################################# # v1.0.0 - 2018-10-19 # - change modul code to package module routine # - change code FHEM conform # - add multiple Attribut to control IoDev modul output # v0.9.1 - 2017-04-25 # - fixed "stop" detection interferring with "extra" strength. # - added new state "grinding". # v0.9 - 2017-04-24 # - added "strength-extra-start-on-device-strength" which allows # brewing with "extra" strength using device buttons. # - added "INITIATED_BREWING" internals to see if FHEM started it. # - fixed timing problem when forcing "grinder" in extra mode. # - fixed "stop" using device button doesn't reset extra mode. # - fixed incorrect placement of start and end anchors in event # regex leading to a too broad event handling for INITIALIZED # events. # - fixed "hotplate off" didn't reset state to "ready". # # v0.8 - 2017-03-18 # - added "controls.txt" to support automatic updates in FHEM. # - changed default value of 'strength extra' to 140% to match # 6 gramms coffee per cup by default. # - improved dev-state icon and documentation. # - fixed possible fallthrough to brew in pre-brew phase. # - fixed a problem that strength extra could be started without # also enabling grinder. # - fixed 'strength extra' not being restored when restarting. # # v0.7 - 2016-08-28 # - added 'reset' defaults to factory settings command. # - added 'get_defaults'. # - added 'strength-extra-pre-brew-*'. # # v0.6 - 2016-08-20 # - final updates & cleanup, preparation for initial checking. # # v0.5 - 2016-08-19 # - added strength "extra" # - added descale detection and descale command # - improvements in connection handling # # v0.4 - 2016-08-17 # - added "set defaults" command # - support reading device type and firmware version # - changed status detection from simple mapping to bitmasks # - handling carafe required dealing with "ready" state # # v0.3 - 2016-08-06 # - added custom state icon (embedded SVG) # # v0.2 - 2016-07-30 # - support auto discovery via UPD broadcast # - start brewing with custom / default settings # - stop brewing while running # # v0.1 - 2016-07-29 # - define Smarter Coffee based on fixed IP or hostname # - set cups, strength, grinder and toggle hotplate # - start brewing # - view detailed status # ############################################################# package main; use strict; use warnings; my $version = "1.0.2"; sub SmarterCoffee_Initialize($) { my ($hash) = @_; $hash->{DefFn} = 'SmarterCoffee::Define'; $hash->{UndefFn} = 'SmarterCoffee::Undefine'; $hash->{GetFn} = 'SmarterCoffee::Get'; $hash->{SetFn} = 'SmarterCoffee::Set'; $hash->{ReadFn} = 'SmarterCoffee::Read'; $hash->{ReadyFn} = 'SmarterCoffee::OpenIfRequiredAndWritePending'; $hash->{NotifyFn} = 'SmarterCoffee::Notify'; $hash->{AttrFn} = 'SmarterCoffee::Attr'; $hash->{AttrList} = "" . "default-hotplate-on-for-minutes " . "ignore-max-cups " . "set-on-brews-coffee " . "strength-coffee-weights " . "strength-extra-percent " . "strength-extra-pre-brew-cups " . "strength-extra-pre-brew-delay-seconds " . "strength-extra-start-on-device-strength:off,weak,medium,strong " . "devioLoglevel:0,1,2,3,4,5 " . $readingFnAttributes; foreach my $d ( sort keys %{ $modules{SmarterCoffee}{defptr} } ) { my $hash = $modules{SmarterCoffee}{defptr}{$d}; $hash->{VERSION} = $version; } } package SmarterCoffee; use strict; use warnings; use POSIX; use GPUtils qw(:all) ; # wird für den Import der FHEM Funktionen aus der fhem.pl benötigt use Data::Dumper; use Socket; use IO::Select; use DevIo; #use HttpUtils; ## Import der FHEM Funktionen BEGIN { GP_Import( qw(readingsSingleUpdate readingsBulkUpdate readingsBeginUpdate readingsEndUpdate CommandAttr defs modules Log3 AttrVal ReadingsVal ReadingsNum Value IsDisabled deviceEvents init_done gettimeofday InternalTimer RemoveInternalTimer DoTrigger parseParams fhem) ); } my $port = 2081; my $discoveryInterval = 60 * 15; my $strengthExtraDefaultPercent = 1.4; my $strengthDefaultWeights = "3.5 3.9 4.3"; my %hotplate = ( default => 15, min => 5, max => 40 ); my %messageMaps = ( status_bitmasks => [ # BIT 1 = ??? # BIT 2 = hotplate # BIT 3 = idle/heating # BIT 4 = brewing/descaling # BIT 5 = grinding # BIT 6 = ready/done # BIT 7 = grinder # BIT 8 = carafe [ '00000000' => { grinder => "disabled", carafe => "missing", hotplate => "off", state => "maintenance" } ], [ '01000000' => { hotplate => "on" } ], [ '00000100' => { state => "ready" } ], [ '00100000' => { state => "ready" } ] , # Set when hotplate is off after being in "heating" state. [ '01000100' => { state => "done" } ], [ '00001000' => { state => "grinding" } ], [ '01010000' => { state => "brewing" } ], [ '01100000' => { state => "heating" } ], [ '00000010' => { grinder => "enabled" } ], [ '00000001' => { carafe => "present" } ], ], water => { # HEX key, only lower 4 bits are used. '00' => { water => "none", water_level => 0 }, '01' => { water => "low", water_level => 25 }, '02' => { water => "half", water_level => 50 }, '03' => { water => "full", water_level => 100 }, }, strength => { # HEX key, only lower 4 bits are used. '00' => { strength => "weak", strength_level => 1 }, '01' => { strength => "medium", strength_level => 2 }, '02' => { strength => "strong", strength_level => 3 }, }, cups => { # HEX key, only lower 4 bits are used. '01' => 1, '02' => 2, '03' => 3, '04' => 4, '05' => 5, '06' => 6, '07' => 7, '08' => 8, '09' => 9, '0a' => 10, '0b' => 11, '0c' => 12 }, grinder => { '00' => "disabled", '01' => "enabled" } ); my %commands = ( reset => "107e", brew => "377e", brew_with_settings => "33########7e", adjust_defaults => "38########7e", get_defaults => "487e", stop => "347e", strength => "35##7e", cups => "36##7e", grinder => "3c7e", hotplate_on_for_minutes => "3e##7e", hotplate_off => "4a7e", carafe_required_status => "4c7e", cups_single_mode_status => "4f7e", info => "647e", history => "467e" ); my @getCommands = ( "info", "carafe_required_status", "cups_single_mode_status", "get_defaults" ); #, "history" my %responseCodes = ( '00' => { message => 'Ok', success => 'yes' }, '01' => { message => 'Ok, brewing in progress', success => 'yes' }, '04' => { message => 'Ok, stopped', success => 'yes' }, '05' => { message => 'No carafe, brewing not possible', success => 'no' }, '06' => { message => 'No water, brewing not possible', success => 'no' }, '69' => { message => 'Invalid command', success => 'no' }, ); sub ParseMessage { my ( $hash, $message ) = @_; $message = ( $hash->{PARTIAL} // "" ) if ( not defined($message) ); if ( $message =~ /^(32|03|47|49|4d|50|65)[0-9a-f]+7e.*/ ) { Log3 $hash->{NAME}, 5, "Connection :: Received from " . ( $hash->{DeviceName} // "unknown" ) . ": $message"; # Handle multiple messages in one frame: my @messages = split( "7e", $message ); if ( int(@messages) > 1 ) { my $failed; for (@messages) { $failed = 1 if ( not ParseMessage( $hash, $_ . "7e" ) ); } return not $failed; } # Handle single message: $hash->{".last_response"} = $message if $message =~ /^(03|49|4d|50|65).+7e.*/; # Parse response of a command. if ( $message =~ /^03([0-9a-f]{2})7e.*/ ) { if ( my $response = ( $responseCodes{$1} // 0 ) ) { UpdateReadings( $hash, sub($) { my ($updateReading) = @_; while ( my ( $key, $value ) = each %{$response} ) { $updateReading->( "last_command_$key", $value ); } $updateReading->( "last_command", $hash->{".last_set_command"} ); }, 1 ); } else { Log3 $hash->{NAME}, 3, "Connection :: Unknown command response '$message'."; } } # Parse history message. if ( $message =~ /^47([0-9a-f]{2})(.+)7e.*/ ) { my @history = split( "7d", $2 ); Log3 $hash->{NAME}, 5, Dumper(@history); #TODO } # Parse default settings message. if ( $message =~ /^49([0-9a-f]+)7e.*/ ) { my %values = ( cups => '0' . substr( $1, 1, 1 ), strength => substr( $1, 2, 2 ), grinder => '0' . substr( $1, 5, 1 ), hotplate => substr( $1, 6, 2 ), ); ParseStatusValues( $hash, \%values ); DoTrigger( $hash->{NAME}, "get_defaults" ); Set( $hash, @{ [ $hash->{NAME}, "defaults" ] } ); } # Parse carafe detection status message. if ( $message =~ /^4d([0-9a-f]{2})7e.*/ ) { UpdateReading( $hash, "carafe_required", ( $1 eq "01" ? "no" : "yes" ) ); } # Parse single cup mode status message. if ( $message =~ /^50([0-9a-f]{2})7e.*/ ) { UpdateReading( $hash, "cups_single_mode", ( $1 eq "00" ? "no" : "yes" ) ); } # Parse info & discovery message. if ( $message =~ /^65([0-9a-f]{2})([0-9a-f]{2})7e.*/ ) { $hash->{FIRMWARE} = ord( pack( "H2", $2 ) ); return 0 if ( $1 ne "02" ); } # Parse status message. if ( $message =~ /^(32[0-9a-f]+7e).*/ and $message ne $hash->{".raw_last_status"} ) { $hash->{".last_status"} = $hash->{".raw_last_status"} = $message = $1; my %values = ( status => substr( $message, 2, 2 ), water => '0' . substr( $message, 5, 1 ), strength => substr( $message, 8, 2 ), cups => '0' . substr( $message, 11, 1 ), ); ParseStatusValues( $hash, \%values ); } $hash->{CONNECTION} = "" . "STATUS: " . ( $hash->{".last_status"} // "n/a" ) . " | COMMAND: " . ( $hash->{".last_command"} // "n/a" ) . " => " . ( $hash->{".last_response"} // "n/a" ); return 1; } return 0; } sub DumpToExpression($) { my $d = Dumper( $_[0] ); $d =~ s/\s+/ /g; $d =~ s/[^\}]*(\{.+\})[^\}]*/$1/; return $d; } sub ParseStatusValues { my ( $hash, $values ) = @_; while ( my ( $mappingKey, $rawValue ) = each %{$values} ) { if ( $mappingKey eq "status" ) { my %status = (); my $unpackedStatusBits = sprintf( '%08b', ord( pack( "H2", $rawValue ) ) ); $hash->{".last_status"} .= " ($unpackedStatusBits)"; for ( @{ $messageMaps{"status_bitmasks"} } ) { my ( $unpackedBitmask, $statusInfo ) = @{$_}; my $bitmask = ord( pack( "B8", $unpackedBitmask ) ); if ( ( $bitmask & ord( pack( "B8", $unpackedStatusBits ) ) ) == $bitmask ) { while ( my ( $k, $v ) = each( %{$statusInfo} ) ) { $status{$k} = $v; } Log3 $hash->{NAME}, 5, "Connection :: Matched all bits of $unpackedBitmask in $unpackedStatusBits. Setting: " . DumpToExpression($statusInfo); } } $values->{$mappingKey} = {%status}; } else { if ( defined( $messageMaps{$mappingKey}{$rawValue} ) ) { $values->{$mappingKey} = $messageMaps{$mappingKey}{$rawValue}; } elsif ( $mappingKey eq "hotplate" ) { $values->{$mappingKey} = { "hotplate_on_for_minutes" => hex($rawValue) }; } else { Log3 $hash->{NAME}, 3, "Connection :: Unknown value '$rawValue' for $mappingKey message part."; $values->{$mappingKey} = {}; } } } Log3 $hash->{NAME}, 5, "Connection :: Parsed message: " . Dumper($values); UpdateReadings( $hash, sub($) { my ($updateReading) = @_; my $state = 0; while ( my ( $n, $readings ) = each %{$values} ) { $readings = { $n => $readings } if ( ref($readings) ne "HASH" ); while ( my ( $name, $value ) = each %{$readings} ) { if ( $name eq "state" ) { $state = $value; } else { $updateReading->( $name, $value ); } } } # Adding calculated readings if ( defined( $values->{"water"} ) and ( my $maxCups = int( ( $values->{"water"}{"water_level"} // 0 ) / 100 * 12 ) ) ) { $maxCups = 3 if ( $maxCups > 3 and ReadingsVal( $hash->{NAME}, "cups_single_mode", "" ) eq "yes" ); $updateReading->( "cups_max", $maxCups ); } # Overriding "ready" state if carafe or water is missing. if ( $state eq "ready" ) { my $cupsOk = ( AttrVal( $hash->{NAME}, "ignore-max-cups", 1 ) or ( ReadingsNum( $hash->{NAME}, "cups_max", 0 ) >= ReadingsNum( $hash->{NAME}, "cups", 0 ) ) ); my $carafeOk = ( ReadingsVal( $hash->{NAME}, "carafe_required", "yes" ) ne "yes" or ( ( $values->{"status"}{"carafe"} // "" ) eq "present" ) ); my $waterOk = ( ( $values->{"water"}{"water_level"} // 0 ) > 0 ); $state = "maintenance" if ( not $carafeOk or not $waterOk or not $cupsOk ); } # Setting status at last when all other readings are updated. $updateReading->( "state", $state ) if $state; } ); } sub Connect($) { my ($hash) = @_; my $isNewConnection = ReadingsVal( $hash->{NAME}, 'state', 'none' ) eq "initializing"; readingsSingleUpdate( $hash, 'state', 'disconnected', 0 ); delete $hash->{INVALID_DEVICE} if defined( $hash->{INVALID_DEVICE} ); if ( $hash->{AUTO_DETECT} ) { RunDiscoveryProcess( $hash, 1 ); } if ( defined( $hash->{DeviceName} ) ) { if ( not( $hash->{DeviceName} =~ m/^(.+):([0-9]+)$/ ) ) { $hash->{DeviceName} .= ":$port"; } main::main::DevIo_CloseDev($hash) if main::DevIo_IsOpen($hash); delete $hash->{DevIoJustClosed} if ( $hash->{DevIoJustClosed} ); return OpenIfRequiredAndWritePending( $hash, $isNewConnection ); } return 0; } sub OpenIfRequiredAndWritePending($;$) { my ( $hash, $initial ) = @_; return main::DevIo_OpenDev( $hash, ( $initial ? 0 : 1 ), "SmarterCoffee::WritePending" ); } sub HandleInitialConnectState($) { my ($hash) = @_; return if ( $hash->{".initial-connection-state"} ); if ( main::DevIo_IsOpen($hash) and ( ReadingsVal( $hash->{NAME}, 'state', 'none' ) eq "disconnected" or ReadingsVal( $hash->{NAME}, 'state', 'none' ) eq "opened" ) ) { $hash->{".initial-connection-state"} = 1; readingsSingleUpdate( $hash, 'state', 'connected', 0 ); Get( $hash, @{ [ $hash->{NAME}, "info" ] } ) if ( not $hash->{AUTO_DETECT} ); Get( $hash, @{ [ $hash->{NAME}, "carafe_required_status" ] } ); Get( $hash, @{ [ $hash->{NAME}, "cups_single_mode_status" ] } ); delete $hash->{".initial-connection-state"}; } } sub WritePending { my ( $hash, $mustSucceed ) = @_; if ( main::DevIo_IsOpen($hash) ) { my $pending = ( $hash->{PENDING_COMMAND} // 0 ); # Handling initial call on a fresh connection HandleInitialConnectState($hash); # Processing pending commands if ( ( $hash->{INVALID_DEVICE} // "0" ) eq "1" ) { readingsSingleUpdate( $hash, 'state', 'invalid', 0 ); } else { if ($pending) { delete $hash->{PENDING_COMMAND} if defined( $hash->{PENDING_COMMAND} ); Log3 $hash->{NAME}, 4, "Connection :: Sending to " . $hash->{DeviceName} . ": $pending"; main::DevIo_SimpleWrite( $hash, $pending, 1 ); $hash->{".raw_last_status"} = ""; my $result = main::DevIo_SimpleReadWithTimeout( $hash, 5 ); if ($result) { $result = Read( $hash, $result ); } else { main::DevIo_Disconnected($hash); } $hash->{INVALID_DEVICE} = "1" if ( $mustSucceed and not $result ); $hash->{PENDING_COMMAND} = $pending if ( not $result ); } } } return undef; } sub Read($;$) { my ( $hash, $buffer ) = @_; # Handle case that fhem reconnected a broken connection and state is "opened". HandleInitialConnectState($hash) if ( not defined($buffer) ); # Abort read if we already detected that the device is invalid. return 0 if ( $hash->{INVALID_DEVICE} // 0 ); # Reset partial data buffer if it exceeds length of 512 (256 bytes) or when $buffer was specified explicitly. $hash->{PARTIAL} = "" if ( not defined( $hash->{PARTIAL} ) or defined($buffer) or length( $hash->{PARTIAL} // "" ) >= 512 ); # Reading available bytes from the socket (if not specified from external). $buffer = main::DevIo_SimpleRead($hash) if ( not defined($buffer) ); return 0 if ( not defined($buffer) ); # Appending message bytes as hex string. $hash->{PARTIAL} .= unpack( 'H*', $buffer ); # Parsing the message and populate readings. if ( $hash->{PARTIAL} ne "" ) { if ( ParseMessage($hash) ) { delete $hash->{PARTIAL}; } else { Log3 $hash->{NAME}, 2, "Connection :: Failed parsing buffer content: " . $hash->{PARTIAL}; return 0; } } return 1; } sub Define($$) { my ( $hash, $def ) = @_; my @param = split( '[ \t]+', $def ); my $name = $hash->{NAME}; # set default settings on first define if ($init_done) { CommandAttr( undef, $name . ' alias Coffee Machine' ) if ( AttrVal( $name, 'alias', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' webCmd strength:cups:start:hotplate:off' ) if ( AttrVal( $name, 'webCmd', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' strength-extra-percent ' . $strengthExtraDefaultPercent ) if ( AttrVal( $name, 'strength-extra-percent', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' default-hotplate-on-for-minutes 15 5=20 8=30 10=35' ) if ( AttrVal( $name, 'default-hotplate-on-for-minutes', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' event-on-change-reading .*' ) if ( AttrVal( $name, 'event-on-change-reading', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' event-on-update-reading last_command.*' ) if ( AttrVal( $name, 'event-on-update-reading', 'none' ) eq 'none' ); CommandAttr( undef, $name . ' devioLoglevel 4' ) if ( AttrVal( $name, 'devioLoglevel', 'none' ) eq 'none' ); } CommandAttr( undef, $name . 'devStateIcon { SmarterCoffee::GetDevStateIcon($name) }' ) if ( AttrVal( $name, 'devStateIcon', 'none' ) eq 'none' or AttrVal( $name, 'devStateIcon', 'none' ) eq '{ SmarterCoffee_GetDevStateIcon($name) }' ); $hash->{VERSION} = $version; if ( int(@param) < 3 ) { $hash->{AUTO_DETECT} = 1; } else { delete $hash->{AUTO_DETECT}; $hash->{DeviceName} = $param[2]; } $hash->{NOTIFYDEV} = "global,$name"; readingsSingleUpdate( $hash, 'state', 'initializing', 0 ); $hash->{".last_command"} = $hash->{".last_response"} = $hash->{".last_status"} = $hash->{".raw_last_status"} = ""; Connect($hash); $modules{SmarterCoffee}{defptr}{CoolTux} = $hash; Log3 $hash->{NAME}, 4, "Instance :: Defined module 'SmarterCoffee': " . Dumper($hash); } sub Undefine($$) { my ( $hash, $arg ) = @_; RemoveInternalTimer($hash); main::DevIo_CloseDev($hash); Log3 $hash->{NAME}, 4, "Instance :: Closed module 'SmarterCoffee': " . Dumper($hash); delete( $modules{SmarterCoffee}{defptr}{CoolTux} ); return undef; } sub Attr(@) { my ( $cmd, $name, $attrName, $attrVal ) = @_; my $hash = $defs{$name}; if ( $attrName eq "devioLoglevel" ) { if ( $cmd eq "set" ) { $hash->{devioLoglevel} = $attrVal; Log3 $name, 3, "SmarterCoffee ($name) - set devioLoglevel to $attrVal"; } elsif ( $cmd eq "del" ) { delete $hash->{devioLoglevel}; Log3 $name, 3, "SmarterCoffee ($name) - delete Internal devioLoglevel"; } } return undef; } sub Get { my ( $hash, @param ) = @_; if ( grep { $_ eq ( $param[1] // "" ) } @getCommands ) { return Set( $hash, @param ) // "Ok :: " . $hash->{".last_response"}; } else { return "Unknown argument $param[1], choose one of " . join( ":noArg ", @getCommands ) . ":noArg"; } } sub Set { my ( $hash, @param ) = @_; my $desiredCups = defined( $hash->{".extra_strength.original_desired_cups"} ) ? $hash->{".extra_strength.original_desired_cups"} : ReadingsVal( $hash->{NAME}, "cups", 1 ); my $optionToMessage = sub($$;$) { my ( $option, $optionValue, $value ) = @_; # Remembering "cups" when a message part is looked-up. $desiredCups = int($optionValue) if ( $option eq "cups" and $optionValue =~ /^[0-9]+$/ ); # Special treatment for hotplate, syntax: "set hotplate (on|off) [5-40]" if ( $option =~ /^hotplate.*/ ) { # Select default time from "[minutes] [cups=minutes]", e.g.: "15 5=20 10=35" means: 15 default, 20 from 5 cups and 35 from 10 cups. my ( $defaultOnForMinutes, $overrides ) = parseParams( AttrVal( $hash->{NAME}, "default-hotplate-on-for-minutes", $hotplate{default} ) ); $defaultOnForMinutes = $defaultOnForMinutes->[0] if ( defined($defaultOnForMinutes) and int($defaultOnForMinutes) > 0 ); for my $key ( sort { $a <=> $b } ( keys %{$overrides} ) ) { $defaultOnForMinutes = $overrides->{$key} if ( int($desiredCups) >= int($key) ); } $value = $optionValue if ( not defined($value) ); $value = $value =~ /^[0-9]+$/ ? int($value) : int($defaultOnForMinutes); $value = $hotplate{max} if ( $value > $hotplate{max} ); $value = $hotplate{min} if ( $value < $hotplate{min} ); UpdateReading( $hash, "hotplate_on_for_minutes", ( $option eq "hotplate_off" ? 0 : $value ) ); return unpack( 'H*', pack( 'C', $value ) ); } elsif ( defined( $messageMaps{$option} ) and defined($optionValue) ) { # Ordinary values are looked up in the message maps (looking up the HEX code that backs a setting). for my $key ( keys %{ $messageMaps{$option} } ) { my $v = $messageMaps{$option}{$key}; if ( ( ref($v) eq "HASH" ? grep( /^$optionValue$/, values %{$v} ) : $v eq $optionValue ) ) { return $key; } } } return undef; }; # Command & params pre-processing my ( $instanceName, $option, $messagePart ) = ( shift @param, shift @param, undef ); # Support "set off" $option = "stop" if ( $option =~ /^off$/i ); # Support "set on" and "set start" if ( ( $option =~ /^on$/i and AttrVal( $hash->{NAME}, "set-on-brews-coffee", "0" ) =~ /^(yes|true|1)$/i ) or $option =~ /^start$/i ) { $option = "brew"; } # Support "set 6-cups" as alias to "set brew 6" (for better readable webCmds) if ( $option =~ /^([0-9]+)-cups(|[\-_,:;][a-z]+)$/i ) { unshift( @param, substr( $2, 1 ) ) if ($2); # supporting "set 3-cups,strong" unshift( @param, $1 ); $option = "brew"; } if ( $option eq "brew" or $option eq "defaults" ) { # Handle extra strong coffee if ( ( $param[1] // ReadingsVal( $hash->{NAME}, "strength", "" ) ) =~ /^extra.*/ ) { # Enable grinder in extra mode if required and option is not defaults. my $grinderEnabled = ( ( $param[3] // ReadingsVal( $hash->{NAME}, "grinder", "" ) ) eq "enabled" ); if ( $option ne "defaults" and not $grinderEnabled and ( $param[3] // "" ) ne "disabled" ) { Set( $hash, @{ [ $hash->{NAME}, "grinder", "enabled" ] } ); $grinderEnabled = 1; $param[3] = "enabled" if defined( $param[3] ); } if ( $option ne "defaults" and $grinderEnabled ) { if ( TranslateParamsForExtraStrength( $hash, \@param, "grind" ) ) { my ( $cups, $error ) = ( $hash->{".extra_strength.desired_cups"}, $hash->{".extra_strength.error_rate"} ); Log3 $hash->{NAME}, 3, "Extra Strength :: Grinding [" . join( " ", @param ) . "] to get $cups cups (error rate: $error%)."; } else { return "strength 'extra' failed. check water level."; } } else { Log3 $hash->{NAME}, 3, "Extra Strength :: Downgrading strength extra to 'strong' for set $option."; $param[1] = "strong"; } } # Handle normal brew or set defaults if ( not defined( $param[0] ) or $param[0] ne "current" ) { # Get message parts my %input = ( "cups" => $param[0], "strength" => $param[1], "hotplate" => $param[2], "grinder" => $param[3] ); my %readingsValues = (%input); my $inputsDefined = 0; for my $key ( keys %input ) { # Translating input to message part. $input{$key} = $optionToMessage->( $key, $input{$key} ) if defined( $input{$key} ); # Taking message part from readings if input didn't specifiy it (using "on" for "hotplate" as the reading would be missleading. if ( not defined( $input{$key} ) ) { $readingsValues{$key} = ( $key eq "hotplate" ? ( ReadingsVal( $hash->{NAME}, "cups_single_mode", "" ) eq "yes" ? 0 : "on" ) : ReadingsVal( $hash->{NAME}, $key, "" ) ); $input{$key} = $optionToMessage->( $key, $readingsValues{$key} ); } # Count if message part was retrieved $inputsDefined++ if defined( $input{$key} ); } if ( $inputsDefined == 4 ) { if ( $option eq "defaults" ) { $option = "adjust_defaults"; $messagePart = $input{strength} . $input{cups} . $input{grinder} . $input{hotplate}; } else { $option = "brew_with_settings"; $messagePart = $input{cups} . $input{strength} . $input{hotplate} . $input{grinder}; UpdateReadings( $hash, sub($) { my ($updateReading) = @_; for my $key ( keys %readingsValues ) { $updateReading->( $key, $readingsValues{$key} ); } } ); } } } # Aborting if option "defaults" was not properly prepared to "adjust_defaults". return undef if $option eq "defaults"; } elsif ( $option =~ /^hotplate.*/ ) { if ( not defined( $param[0] ) ) { $param[0] = ( $option ne "hotplate_on_for_minutes" and ReadingsVal( $hash->{NAME}, "hotplate", "" ) ne "off" ) ? "off" : "on"; } $option = $param[0] =~ /^(0|no|disable|off).*$/i ? "hotplate_off" : "hotplate_on_for_minutes"; $messagePart = $optionToMessage->( $option, $param[0], $param[1] ); } else { if ( defined( $param[0] ) ) { # Resetting "extra" strength mode when strength is updated. delete $hash->{".extra_strength.enabled"} if ( $option eq "strength" and $param[0] ne "extra" and $hash->{".extra_strength.enabled"} ); # Eager updating strength, cups and grinder reading to avoid that widget updates are slower than starting a "brew". UpdateReading( $hash, $option, $param[0] ) if ( $option =~ /^(strength|cups|grinder)$/ ); # Aborting device update when strength is "extra". return undef if ( $option eq "strength" and $param[0] eq "extra" ); # Aborting device update if grinder is not changed (every command sent to the coffee machine flips the grinder setting). return undef if ( $option eq "grinder" and $param[0] eq ReadingsVal( $hash->{NAME}, "grinder", "" ) ); } # Resetting internal states before executing "stop". if ( $option eq "stop" and ( $param[0] // "" ) ne "no-reset" ) { ResetState($hash); } $messagePart = $optionToMessage->( $option, $param[0] ); } # Command execution if ( defined( $commands{$option} ) ) { my $message = $commands{$option}; # Replacing placeholders with value. $message =~ s/#+/$messagePart/ if ( defined($messagePart) and $messagePart =~ /^[a-f0-9]{2,}$/ ); if ( $message =~ /.*#.*/ ) { return "Option $option: Unsupported params: " . join( " ", @param ); } else { $hash->{".last_set_command"} = $option . ( int(@param) ? " " . join( " ", @param ) : "" ); $hash->{"PENDING_COMMAND"} = $hash->{".last_command"} = $message; Log3 $hash->{NAME}, 4, "Connection :: Sending message: $message [" . $hash->{".last_set_command"} . "]"; WritePending( $hash, ( $option eq "info" ) ); } return undef; } elsif ( $option eq "disconnect" or $option eq "reconnect" ) { # This option is primarily to test if reconnect works. main::DevIo_Disconnected($hash); Connect($hash) if ( $option eq "reconnect" ); return undef; } elsif ( $option ne "?" and $option ne "help" ) { return "Unknown option: $option with params: " . join( " ", @param ); } my @strength = split( ",", "weak,medium,strong,extra" ); pop(@strength) if ( not IsExtraStrengthModeAvailable($hash) ); return "Unknown argument $option, choose one of" . " brew" . " defaults" . " reset:noArg" . " stop:noArg" . " strength:" . join( ",", @strength ) . " cups:slider,1,1,12" . " grinder:enabled,disabled" . " hotplate" . " hotplate_on_for_minutes:slider,5,5,40"; } sub ResetState($) { my ($hash) = @_; ResetBrewState($hash); ResetExtraStrengthMode($hash); } sub Notify($$) { my ( $hash, $eventHash ) = @_; my $name = $hash->{NAME}; my $senderName = $eventHash->{NAME}; # Return without any further action if the module is disabled or the event is not from this module or global. return "" if ( IsDisabled($name) or ( $senderName ne $name and $senderName ne "global" ) ); if ( my $events = deviceEvents( $eventHash, 1 ) ) { if ( $senderName eq "global" ) { ReadConfiguration($hash) if ( grep( m/^(INITIALIZED|REREADCFG)$/, @{$events} ) ); } else { for ( @{$events} ) { if ($_) { ProcessBrewStateEvents( $hash, $_ ); ProcessEventForExtraStrength( $hash, $_ ); LogCommands( $hash, $_ ); } } } } } sub ReadConfiguration($) { my ($hash) = @_; # Restoring extra strength $hash->{".extra_strength.enabled"} = 1 if ( ReadingsVal( $hash->{NAME}, "strength", "" ) =~ /^extra.*/ ); } sub LogCommands($$) { my ( $hash, $event ) = @_; if ( $event =~ /^last_command_success:\s*(yes|no)\s*$/i and ( my $command = ReadingsVal( $hash->{NAME}, "last_command", 0 ) ) ) { my $message = ReadingsVal( $hash->{NAME}, "last_command_message", "" ); if ( $1 eq "yes" ) { Log3 $hash->{NAME}, 4, "Command :: Success [$command]; Message: $message"; } else { Log3 $hash->{NAME}, 3, "Command :: Failed [$command]; Cause: $message"; } } } sub ProcessBrewStateEvents($$) { my ( $hash, $event ) = @_; # Setting "INITIATED_BREWING" when brewing was initiated by a command (and not by using the machine's buttons) if ( $event =~ /^last_command_success:\s*yes\s*$/i and ReadingsVal( $hash->{NAME}, "last_command", 0 ) =~ /^brew.*/ ) { $hash->{"INITIATED_BREWING"} = 1; $hash->{".brew-state"} = "brewing"; } elsif ( $event =~ /^state:\s*(brewing|grinding)/ ) { $hash->{".brew-state"} = $1; } elsif ( $event =~ /^state:\s*done/ ) { ResetBrewState($hash); } elsif ( $event =~ /^state:\s*(.+)$/ and ( $hash->{".brew-state"} // "" ) =~ /^(brewing|grinding)$/ ) { Log3 $hash->{NAME}, 3, "Found state change from 'brewing' to '$1'. This looks like an abort, resetting all states to initial."; ResetState($hash); } } sub ResetBrewState($) { my ($hash) = @_; delete $hash->{".brew-state"} if defined( $hash->{".brew-state"} ); delete $hash->{"INITIATED_BREWING"} if defined( $hash->{"INITIATED_BREWING"} ); } sub ProcessEventForExtraStrength($$) { my ( $hash, $event ) = @_; if ( $event =~ /^strength:\s*extra\s*$/ ) { # Listen to "set strength extra" and enable it if available. if ( not( EnableExtraStrengthMode($hash) ) ) { Log3 $hash->{NAME}, 3, "Extra-Strength :: Downgrading strength 'extra' to 'strong'"; fhem( "sleep 0.1 fix-strength ; set " . $hash->{NAME} . " strength strong" ); } } elsif ( $event =~ /^state:\s*brewing/ and not $hash->{"INITIATED_BREWING"} ) { # Monitor event that brewing was started on the device without grinder and upgrade to 'extra' if configured in attributes. if ( ReadingsVal( $hash->{NAME}, "grinder", "-" ) eq "disabled" and ( my $cups = int( ReadingsVal( $hash->{NAME}, "cups", 0 ) ) ) > 0 and ( my $strength = ReadingsVal( $hash->{NAME}, "strength", "" ) ) eq AttrVal( $hash->{NAME}, "strength-extra-start-on-device-strength", "off" ) and EnableExtraStrengthMode($hash) ) { Log3 $hash->{NAME}, 3, "Extra-Strength :: Upgrading brewing $cups cups started with disabled grinder and strength '$strength' to strength 'extra'."; Set( $hash, @{ [ $hash->{NAME}, "stop" ] } ); Set( $hash, @{ [ $hash->{NAME}, "brew", $cups, "extra" ] } ); } } elsif ( ( $hash->{".extra_strength.enabled"} or $hash->{".extra_strength.phase-2"} ) ) { # Listen to "set strength ?" while in extra mode and revert it to extra shortly. if ( $event =~ /^strength:\s*([^\s]+)\s*$/ ) { fhem( "sleep 0.1 fix-strength ; set " . $hash->{NAME} . " strength extra" ); } elsif ( $event =~ /^state:\s*done/ ) { # Finishing first round (grinding & first brew are done here) if ( ( my $delay = int( $hash->{".extra_strength.pre_brew_phase_delay"} // 0 ) ) > 0 ) { InternalTimer( gettimeofday() + $delay, "SmarterCoffee::ExtraStrengthHandleBrewing", $hash, 0 ); } else { if ( int( $hash->{".extra_strength.original_desired_cups"} // 0 ) > 0 ) { Set( $hash, @{ [ $hash->{NAME}, "cups", $hash->{".extra_strength.original_desired_cups"} ] } ); } ResetExtraStrengthMode($hash); } } elsif ( $event =~ /^state:\s*brewing/ and not $hash->{".extra_strength.phase-2"} ) { # Entering phase-2: Brewing after initial grinding at different settings. $hash->{".extra_strength.phase-2"} = ExtraStrengthHandleBrewing($hash); } } } sub ExtraStrengthHandleBrewing($) { my ($hash) = @_; my @params = ( ReadingsVal( $hash->{NAME}, "cups", "-" ), ReadingsVal( $hash->{NAME}, "strength", "-" ), ReadingsVal( $hash->{NAME}, "hotplate_on_for_minutes", ( ReadingsVal( $hash->{NAME}, "cups_single_mode", "" ) eq "yes" ? 0 : "on" ) ), "disabled" ); if ( TranslateParamsForExtraStrength( $hash, \@params, "brew" ) ) { # Resetting brew state to ensure it doesn't interfere with stop command that runs with "no-reset" option. ResetBrewState($hash); # Stopping brewing after initial grinding (skip stop if we are in phase-2 and came here due to pre-brew delay) Set( $hash, @{ [ $hash->{NAME}, "stop", "no-reset" ] } ) if not $hash->{".extra_strength.phase-2"}; unshift( @params, "brew" ); unshift( @params, $hash->{NAME} ); my $phase = int( $hash->{".extra_strength.pre_brew_phase_delay"} // 0 ) > 0 ? "2 (pre brew)" : "2"; Log3 $hash->{NAME}, 4, "Extra-Strength :: Phase $phase [set " . join( " ", @params ) . "]"; Set( $hash, @params ); return 1; } return 0; } sub IsExtraStrengthModeAvailable($;$) { my ( $hash, $slient ) = @_; my $extraPercent = AttrVal( $hash->{NAME}, "strength-extra-percent", $strengthExtraDefaultPercent ); my $preBrew = int( AttrVal( $hash->{NAME}, "strength-extra-pre-brew-cups", 1 ) ) * int( AttrVal( $hash->{NAME}, "strength-extra-pre-brew-delay-seconds", 0 ) ); if ( $extraPercent > 0 and ( $extraPercent != 1 or $preBrew > 0 ) and $extraPercent < 2.5 ) { return 1; } else { Log3 $hash->{NAME}, ( ( $slient // 1 ) ? 5 : 3 ), "Extra-Strength :: Strength 'extra' is disabled as [strength-extra-percent = $extraPercent] is out of range (0 < x < 2.5)"; return 0; } } sub EnableExtraStrengthMode($) { my ($hash) = @_; return 1 if ( $hash->{".extra_strength.enabled"} ); if ( IsExtraStrengthModeAvailable( $hash, 0 ) ) { Log3 $hash->{NAME}, 4, "Extra-Strength :: Entering extra strength mode."; $hash->{".extra_strength.enabled"} = 1; return 1; } else { return 0; } } sub ResetExtraStrengthMode($;$) { my ( $hash, $partial ) = @_; Log3 $hash->{NAME}, 4, ( "Extra-Strength :: Resetting state to initial (partial: " . ( $partial // 0 ) . ")." ); foreach my $key ( keys %{$hash} ) { my $resetableKey = ( $key =~ /^\.extra_strength\..+$/ and $key ne ".extra_strength.enabled" ); if ( ( $partial // 0 ) and $resetableKey ) { $resetableKey = ( not $key =~ /.+\.(original_desired_cups|desired_cups|pre_brew_phase_delay|phase-2).+$/ ); } if ($resetableKey) { Log3 $hash->{NAME}, 5, "Extra-Strength :: Resetting $key"; delete $hash->{$key}; } } } sub TranslateParamsForExtraStrength($$$) { my ( $hash, $params, $phase ) = @_; return 0 if ( not( EnableExtraStrengthMode($hash) ) ); if ( $phase eq "grind" ) { my $extraPercent = AttrVal( $hash->{NAME}, "strength-extra-percent", $strengthExtraDefaultPercent ); my @strengths = ( "weak", "medium", "strong" ); my @weights = split( /\s+/, AttrVal( $hash->{NAME}, "strength-coffee-weights", $strengthDefaultWeights ) ); while ( int(@weights) < 3 ) { push( @weights, ( int(@weights) ? $weights[ int(@weights) - 1 ] : 4.3 ) ); } Log3 $hash->{NAME}, 4, "Extra-Strength :: Reference weights: " . join( " ", @weights ) . " (" . join( " ", @strengths ) . ")"; my $desiredCups = $params->[0] // ReadingsVal( $hash->{NAME}, "cups", 0 ); my $maxCups = ReadingsVal( $hash->{NAME}, "cups_max", $desiredCups ); $desiredCups = $maxCups if ( $desiredCups > $maxCups ); my %grind = ( "cups" => undef, "desired" => $desiredCups, "strength" => undef, "delta" => undef, "error" => undef ); while ( $desiredCups > 0 and not defined( $grind{cups} ) ) { my $targetWeight = $desiredCups * $weights[2] * $extraPercent; for ( my $i = 0 ; $i < int(@weights) ; $i++ ) { if ( ( $weights[$i] // -1 ) > 0 and $targetWeight > 0 ) { my $cups = int( $targetWeight / $weights[$i] ) + ( $extraPercent > 1 ? 1 : 0 ); my $weight = $cups * $weights[$i]; my $delta = ( $targetWeight > $weight ? ( $targetWeight - $weight ) : ( $weight - $targetWeight ) ); my $error = int( ( 1 - ( ( $targetWeight - $delta ) / $targetWeight ) ) * 100 ); Log3 $hash->{NAME}, 4, "Extra-Strength :: GC: $cups (" . $strengths[$i] . "), DC: $desiredCups, D: $delta (e:$error%), W: $weight, T: $targetWeight"; if ( $cups <= $maxCups and ( not defined( $grind{delta} ) or $grind{delta} > $delta ) ) { $grind{desired} = $desiredCups; $grind{cups} = $cups; $grind{delta} = $delta; $grind{strength} = $strengths[$i]; $grind{error} = $error; } } } $desiredCups--; } if ( defined( $grind{cups} ) ) { $hash->{".extra_strength.original_desired_cups"} = $grind{desired}; $hash->{".extra_strength.desired_cups"} = $grind{desired}; $hash->{".extra_strength.error_rate"} = $grind{error}; $params->[0] = $grind{cups}; $params->[1] = $grind{strength}; return 1; } else { Log3 $hash->{NAME}, 2, "Extra-Strength :: Failed calculating extra strength (not enough water?). Ordinary coffee strength will be applied."; } } elsif ( $phase eq "brew" and defined( $hash->{".extra_strength.desired_cups"} ) ) { my ( $preBrewCups, $preBrewDelay ) = ( int( AttrVal( $hash->{NAME}, "strength-extra-pre-brew-cups", 1 ) ), int( AttrVal( $hash->{NAME}, "strength-extra-pre-brew-delay-seconds", 0 ) ) ); if ( $preBrewCups > 0 and $preBrewDelay > 0 and $preBrewCups < $hash->{".extra_strength.desired_cups"} and not $hash->{".extra_strength.pre_brew_phase_delay"} ) { $hash->{".extra_strength.pre_brew_phase_delay"} = $preBrewDelay; $hash->{".extra_strength.desired_cups"} -= $preBrewCups; $params->[0] = $preBrewCups; } else { $params->[0] = $hash->{".extra_strength.desired_cups"}; ResetExtraStrengthMode( $hash, 1 ); } return 1; } return 0; } sub UpdateReading($$$) { my ( $hash, $name, $value ) = @_; UpdateReadings( $hash, sub($) { ( $_[0] )->( $name, $value ) } ); } sub UpdateReadings($$;$) { my ( $hash, $callback, $forceUpdate ) = @_; $forceUpdate = ( ( $forceUpdate // 0 ) or defined( AttrVal( $hash->{NAME}, "event-on-update-reading", undef ) ) or defined( AttrVal( $hash->{NAME}, "event-on-change-reading", undef ) ) ); my $updated = 0; my $updater = sub { my ( $name, $value ) = @_; return if not( defined($name) and defined($value) ); my $changed = ReadingsVal( $hash->{NAME}, $name, "##undefined" ) ne $value; if ( $changed or $forceUpdate ) { readingsBulkUpdate( $hash, $name, $value ); $updated = 1 if ($changed); } $updated = 1 if ( $name eq "state" and ReadingsVal( $name, 'state', 'none' ) ne $value ); }; readingsBeginUpdate($hash); $callback->($updater); readingsEndUpdate( $hash, ( $updated or $forceUpdate ) ); } sub RunDiscoveryProcess($;$) { my ( $hash, $skipConnect ) = @_; if ( Discover($hash) and not $skipConnect ) { Connect($hash); } InternalTimer( gettimeofday() + $discoveryInterval, "SmarterCoffee::RunDiscoveryProcess", $hash, 0 ); } sub InetSocketAddressString($) { my ( $sport, $inetAddress ) = sockaddr_in( $_[0] ); return inet_ntoa($inetAddress) . ":$sport"; } sub Discover($) { my ($hash) = @_; my $existingDeviceName = ( $hash->{DeviceName} // "" ); my $broadcastAddress = sockaddr_in( $port, INADDR_BROADCAST ); Log3 $hash->{NAME}, 4, "Discovery :: Broadcasting discovery request to " . InetSocketAddressString($broadcastAddress) . " (already discovered: $existingDeviceName)"; socket( my $socket, AF_INET, SOCK_DGRAM, getprotobyname('udp') ); setsockopt( $socket, SOL_SOCKET, SO_BROADCAST, 1 ); send( $socket, 'd~', 0, $broadcastAddress ); my $wait = IO::Select->new($socket); while ( $wait->can_read(10) ) { my $deviceAddress = recv( $socket, my $message, 128, 0 ); my $inetSocketAddress = InetSocketAddressString($deviceAddress); $message = unpack( 'H*', $message ); Log3 $hash->{NAME}, 4, "Discovery :: Received message $message from $inetSocketAddress"; if ( $message =~ /^65.*7e.*/ and ParseMessage( $hash, $message ) ) { my ( $sport, $inetAddress ) = sockaddr_in($deviceAddress); if ( my ($hostname) = gethostbyaddr( $inetAddress, AF_INET ) ) { $hash->{DeviceName} = $hostname . ":$sport"; } else { $hash->{DeviceName} = $inetSocketAddress; } if ( $existingDeviceName ne $hash->{DeviceName} ) { Log3 $hash->{NAME}, 3, "Discovery :: Discovered smarter coffee machine (message=$message): " . $hash->{DeviceName}; } last; } } close $socket; if ( !defined( $hash->{DeviceName} ) ) { my $recommendation = "Recommendation: Specify in fhem config at: " . "'define " . $hash->{NAME} . " SmarterCoffee ' or check network / coffee machine."; Log3 $hash->{NAME}, 2, "Discovery :: Failed discovering smarter coffee machine. $recommendation"; return 0; } else { return $existingDeviceName ne $hash->{DeviceName}; } } ## ------------------------------------------------------------------------------------------------------------------- ## The following section deals with displaying dev state (it is completely optional with regards to the functionality) my $SmarterCoffee_StatusIconSVG = < XML sub GetDevStateIcon { my ( $name, $colors ) = @_; my ( $state, $icon ) = ( Value($name), $SmarterCoffee_StatusIconSVG ); my $noWater = ( ReadingsVal( $name, "water", "none" ) eq "none" and $state eq "maintenance" ); my $waterLevel = ReadingsVal( $name, "water_level", "0" ); $state = "brewing" if ( $state eq "grinding" ); $icon =~ s/(name="ready" opacity)="1"/$1="0"/g if $state ne "ready"; $icon =~ s/(name="brewing" opacity)="1"/$1="0"/g if $state ne "brewing"; $icon =~ s/(name="coffee-level" opacity)="1"/$1="0"/g if ( $state ne "brewing" and $state ne "done" ); $icon =~ s/(name="(carafe|coffee-level)" opacity)="1"/$1="0"/g if ( ReadingsVal( $name, "carafe", "present" ) ne "present" or $noWater ); $icon =~ s/(name="heating" opacity)="1"/$1="0"/g if ReadingsVal( $name, "hotplate", "off" ) ne "on"; $icon =~ s/(name="water-level.*" opacity)="1"/$1="0"/g; $icon =~ s/(name="water-level-$waterLevel" opacity)="0"/$1="1"/; $icon =~ s/(name="no-water" opacity)="0"/$1="1"/ if $noWater; # Adjusting the icon color my @stateColors = split( /\s+/, ( $colors // "" ) ); for ( my $i = 0 ; $i < int(@stateColors) ; $i++ ) { $stateColors[$i] = undef if ( $stateColors[$i] =~ /^[^#a-z0].*/i ); } my %cm = ( "default" => ( $stateColors[0] // "#7b7b7b" ), "ready" => ( $stateColors[1] // "green" ), "brewing" => ( $stateColors[2] // "chocolate" ), "done" => ( $stateColors[3] // "#336699" ), ); if ( my $stateColor = ( $cm{$state} ? $cm{$state} : $cm{default} ) ) { $icon =~ s/(stroke|fill):#000000/$1:$stateColor/g; } # Removing any CR/LF to avoid wrapping in
 tags
    $icon =~ s/[\r\n]//g;

    return $icon;
}

## -------------------------------------------------------------------------------------------------------------------
## Documentation follows

1;

=pod
=item device
=item summary Controls a Wi-Fi Smarter Coffee machine via network connection
=begin html


SmarterCoffee

    Integrates the equally called Wi-Fi coffee machine (http://smarter.am/) with FHEM.

    Prerequisite:
    Make sure the machine can be controlled by the smarter mobile app when both are connected to the same network as fhem.
    If in doubt check the official documentation or official support forum to get help with integrating the coffee machine into your network.

Define
    define <name> SmarterCoffee (<hostname>)

    Hostname is optional, if omitted the name is auto-detected via UDP broadcast.

    Examples:
    • define coffee-machine SmarterCoffee
      Connects with the first coffee machine that answers the UDP broadcast.

    • define coffee-machine SmarterCoffee smarter-coffee.fritz.box
      Connects with the coffee machine at address 'smarter-coffee.fritz.box'.

    • define coffee-machine SmarterCoffee 192.168.2.56:2081
      Connects with the coffee machine at '192.168.2.56' using port 2081 (= default)

Readings
  • state
    Device state, can be one of:
    • disconnected: No connection to coffee machine.
    • opened / connected: Intermediate states after connection has been established but before the machine's state is known.
    • invalid: The connected device is not a coffee machine.
    • ready: Ready to start brewing.
    • grinding: Grinding coffee.
    • brewing: Brewing coffee.
    • done: Done brewing.
    • heating: Keeping coffee warm or reheating.
    • maintenance: Maintenance is needed to get ready for brewing (e.g. water or carafe is missing).

  • hotplate_on_for_minutes
    Shows the number of minutes that the hotplate will be on when it was turned on via "set <name> hotplate" or "set <name> brew".

  • carafe
    One of "present" or "missing" as the carafe is detected as being present or not.
    (state ready turns to maintenance when carafe is missing and carafe_required is yes)

  • carafe_required
    Is "yes" or "no" as the carafe is required to start brewing or not.
    This option can be configured via the smarter mobile app. Read disclaimer before turning off carafe detection.

  • cups_max
    The estimated maximum brewable cups when taking current water level into account.

  • cups_single_mode
    Is "yes" or "no" as the single cup mode is active or not.
    This option can be configured via the smarter mobile app. When enabled "set <name> brew" will not enable the hotplate and "cups_max" is limited to 3. In single cup mode, cups [1,2,3] is used for one [small,medium,large] cup.

  • water and water_level
    Is [none, low, half, full] and [0, 25, 50, 100] indicating the amount of water that remains in the tank.
    (state ready turns to maintenance when water_level is "0")

  • last_command.*
    Is updated with the last executed set or get command string, including device response and success information.

  • Further readings match set commands and reflect the corresponding machine state. See Set section below.

Get
  • get <name> info
    Retrieves firmware & device type information and updates internals.

  • get <name> carafe_required_status
    Retrieves whether carafe is required for brewing and updates reading "carafe_required".

  • get <name> cups_single_mode_status
    Retrieves whether single cup mode is active and updates reading "cups_single_mode".

  • get <name> get_defaults
    Retrieves and applies previously set machine defaults. Triggers the event "defaults" after retrieval but before applying them.

Set
  • set <name> brew
    Start brewing with settings displayed in readings and "default-hotplate-on-for-minutes" for hotplate.
  • set <name> brew current
    Start brewing with current machine settings.
  • set <name> brew [1 - 12] ([weak, medium, strong, extra]) ([5-40]) ([enabled, disabled])
    Start brewing the specified amount of cups at optionally specified strength, hotplate and grinder with non-specified settings used from readings and "default-hotplate-on-for-minutes" for hotplate.

    E.g. "set <name> brew 5 medium" brews 5 cups of medium coffee reusing current hotplate and grinder settings.

  • set <name> defaults ([1 - 12]) ([weak, medium, strong]) ([5-40]) ([enabled, disabled])
    Sets the machine defaults to the current settings optionally overridden by specified amount of cups, strength, hotplate and grinder. Non-specified settings are used from readings and "default-hotplate-on-for-minutes" for hotplate.
    Note: Machine defaults are applied by the coffee machine after every brew and when stopping or turning off. Readings are updated accordingly when defaults are applied.

    E.g. "set <name> defaults 5 medium" sets defaults to 5 cups of medium coffee and reuses current hotplate and grinder settings as future defaults.

  • set <name> stop
    Stop brewing and disable hotplate if on.

  • set <name> strength [weak, medium, strong, extra]
    Toggles the strength via the amount of coffee beans to use per cup when grinding.

    The strength "extra" is special in that it is not natively supported by the machine itself. To brew coffee with "extra" strength a custom sequence similar to the following is started:
    set <name> brew <cups + 1> strong on enabled ;; sleep <cups * 1.5> ;; set <name> stop ;; set <name> brew <cups> on disabled.
    See also attributes "strength-extra-percent" and "strength-coffee-weights" which control the calculation of actual strength and cup counts.

  • set <name> grinder [enabled, disabled]
    Toggles whether grinder is used when brewing coffee. Ground coffee has to be added manually to the filter and strength settings are ignored when grinder is disabled.

  • set <name> cups [1 - 12]
    Toggles the amount of cups (~100ml) to brew.

  • set <name> [1 - 12]-cups
    Is an alias to "set <name> brew [1 - 12]". This adds support for web commands like "8-cups" and "3-cups,strong".

  • set <name> hotplate <command>
    Toggles the hotplate that keeps the coffee warm after brewing.

    <command> is one of:
    • on
      On for "default-hotplate-on-for-minutes" minutes (defaults to 15 minutes)
    • on [5 - 40]
      On for the specified amount of minutes
    • off

  • set <name> hotplate_on_for_minutes [5 - 40]
    Is an alias to "set <name> hotplate on [5 - 40]".

  • set <name> reconnect
    Disconnects, optionally runs discovery (if hostname or address was omitted in the device definition) and reconnects.

  • set <name> reset
    Resets machine to factory default, excluding WLAN settings.

Attributes
  • attr <name> devStateIcon { SmarterCoffee::GetDevStateIcon($name) }

    The function SmarterCoffee::GetDevStateIcon($name[, "...colors..."]) renders a custom dev state icon that displays the machine states (ready, brewing, done) and shows information on carafe, hotplate and water level.

    The icon is monochrome using a default color that may change to highlight states: ready, brewing, done. Built-in colors can be adjusted with the second parameter of SmarterCoffee::GetDevStateIcon.
    E.g. using "attr <name> devStateIcon { SmarterCoffee::GetDevStateIcon($name, '#7b7b7b green chocolate #336699' }" sets colors for default, ready, brewing and done.

    Colors are specified as HTML color values delimited by whitespace using a fixed order of "default ready brewing done". Use '-' or '0' to substitute a color with the built-in or the default color within the color sequence. E.g. a color sequence of 'blue - 0 0' uses blue for all states except "ready" which uses the built-in color green.

  • attr <name> default-hotplate-on-for-minutes 15
    Defines how long the hotplate is heating coffee when turning it on without specifying a time or when brewing coffee without specifying a valid time value for hotplate.
    Values of 15, 5 or 40 minutes are used as this attribute is not specified, invalid or greater than 40 (5 <= x <= 40, with x defaulting to 15).

    In addition to a fixed value, the hotplate can also be turned on relative to the number of cups that are brewed. E.g. setting a value of "15 5=20 10=35" means: 15 from 1 to 4 cups, 20 from 5 cups and 35 from 10 cups.

  • attr <name> ignore-max-cups [1, 0]
    Toggles whether the reading "cups_max" influences the state "ready". By default "attr <name> ignore-max-cups 1" is assumed which means that the state "ready" is not related to "cups_max" if this attribute is not set. Set this attribute to "0" if the state should turn to "maintenance" when the selected cup count is larger than "cups_max".

  • attr <name> set-on-brews-coffee [0, 1]
    Toggles whether the command "set <name> on" is an alias to "set <name> brew". By default this is disabled to avoid accidental coffee brewing.

  • attr <name> strength-extra-percent 1.4
    Specifies the percentage of coffee to use relative to strength "strong" when brewing coffee with extra strength. A value of "1.4" brews coffee that is 140% the strength of "strong" respectively "0.6" brews coffee that is 60% the strength. Setting strength-extra-percent to 0 disables support for extra strength.

    Note: Brewing coffee with extra strength uses strengths and cup counts natively supported by the machine and the configured percentage is likely not matched exactly. Best results are achieved with 4 - 8 cups and a full water tank as it allows to use most variations to get close to the target.

  • attr <name> strength-extra-pre-brew-delay-seconds 0
    Specifies a delay in seconds when brewing coffee with extra strength which is used to split the brewing operation in a pre-brew and the normal brew phase. The pre-brew phase brews a small amount of cups (usually 1) and pauses for a couple of seconds before continuing with the rest of the cups. This mode can help to overcome limitations with grounds being too coarse to provide good taste at standard brewing speed. Specifying 0 disables "pre-brew".

  • attr <name> strength-extra-pre-brew-cups 1
    Specifies the number of cups that are brewed first before delaying brewing in extra mode. Specifying 0 disables "pre-brew".

  • attr <name> strength-extra-start-on-device-strength [off, weak, medium, strong]
    Specifies a strength level that maps to strength 'extra' when starting brewing without grinder using the buttons at the coffee machine. By default this option is set to "off" which means that strength 'extra' can only be used when starting brewing via FHEM.
    E.g. a value of "weak" allows to brew coffee with extra strength by pressing the start button at the coffee machine with strength set to "weak" and grinder set to "disabled" (= "Filter" in the display).

    Note: Brewing started from FHEM is never affected by this setting.

  • attr <name> strength-coffee-weights 3.5 3.9 4.3
    Is the amount of coffee that the grinder produces per cup depending on the selected strength. This setting does not control the amount it only tells the module what the grinder will produce. Changing the default values is therefore only required if the coffee machine produces different results on the actually used beans.
    The amounts are specified in grams per strength [weak, medium, strong] using whitespace as delimiter.

    The purpose of this metric is to calculate the actual strength and cups to use when grinding coffee with extra strength. E.g. for 140% extra strength, 4 cups require (4 * 4.3 * 1.4) = 24.08 gramms of coffee. In this example the closest match is grinding 7 cups with weak strength which produces (7 * 3.5) = 24.5 gramms. The actual brewing is then performed with 4 cups as originally requested.

    The algorithm tries to find the closest matching cup counts and strength value towards the target amount of coffee required for extra strength. It is technically not possible to control the amount of coffee directly, therefore the grams specified for the different strengths are used select between natively supported strengths weak, medium, strong that match the desired target the closest. Water level is also taken into account as cup count is truncated by the coffee machine when grinding, depending on the amount of available water. Decisions may vary depending on cups and available water, keep water level at maximum to get best results.

    Note: SCAE (Speciality Coffee Association of Europe) recommends ~6 gramms of coffee per cup. To come close, 140% is the default value for extra strength assuming that the default values for strength-coffee-weights apply to the used coffee beans. As the density of coffee beans differs these defaults may be inappropriate. To get better results with a certain kind of beans it may make sense to measure the actual produced weights and adjust the values (strength-coffee-weights and strength-extra-percent) accordingly.

=end html =cut