commit b35d0fda6002ecc23c5407c3bca39477026035a1 Author: Marko Oldenburg Date: Thu Oct 26 06:27:08 2017 +0200 first commit diff --git a/98_SmarterCoffee.pm b/98_SmarterCoffee.pm new file mode 100644 index 0000000..30d2b7c --- /dev/null +++ b/98_SmarterCoffee.pm @@ -0,0 +1,1579 @@ +############################################################# +# +# Copyright notice +# +# (c) 2016 +# Copyright: Juergen Kellerer (juergen at k123 dot eu) +# 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$ +# +############################################################# +# +# 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: 0.9.1 +# +############################################################# +# +# 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; + +use Data::Dumper; +use Socket; +use IO::Select; + +use DevIo; +use HttpUtils; + +my $SmarterCoffee_Port = 2081; +my $SmarterCoffee_DiscoveryInterval = 60 * 15; +my $SmarterCoffee_StrengthExtraDefaultPercent = 1.4; +my $SmarterCoffee_StrengthDefaultWeights = "3.5 3.9 4.3"; +my %SmarterCoffee_Hotplate = (default => 15, min => 5, max => 40); + +my %SmarterCoffee_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 %SmarterCoffee_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 @SmarterCoffee_GetCommands = ("info", "carafe_required_status", "cups_single_mode_status", "get_defaults"); #, "history" + +my %SmarterCoffee_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 SmarterCoffee_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 SmarterCoffee_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 = ($SmarterCoffee_ResponseCodes{$1} // 0)) { + SmarterCoffee_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); + + Log 2, 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), + ); + + SmarterCoffee_ParseStatusValues($hash, \%values); + DoTrigger($hash->{NAME}, "get_defaults"); + SmarterCoffee_Set($hash, @{[ $hash->{NAME}, "defaults" ]}); + } + + # Parse carafe detection status message. + if ($message =~ /^4d([0-9a-f]{2})7e.*/) { + SmarterCoffee_UpdateReading($hash, "carafe_required", ($1 eq "01" ? "no" : "yes")); + } + + # Parse single cup mode status message. + if ($message =~ /^50([0-9a-f]{2})7e.*/) { + SmarterCoffee_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), + ); + + SmarterCoffee_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 SmarterCoffee_DumpToExpression($) { + my $d = Dumper($_[0]); + $d =~ s/\s+/ /g; + $d =~ s/[^\}]*(\{.+\})[^\}]*/$1/; + return $d; +} + +sub SmarterCoffee_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 (@{$SmarterCoffee_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: ".SmarterCoffee_DumpToExpression($statusInfo); + } + } + $values->{$mappingKey} = { %status }; + } else { + if (defined($SmarterCoffee_MessageMaps{$mappingKey}{$rawValue})) { + $values->{$mappingKey} = $SmarterCoffee_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); + + SmarterCoffee_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 SmarterCoffee_Connect($) { + my ($hash) = @_; + + my $isNewConnection = $hash->{STATE} eq "initializing"; + + RemoveInternalTimer($hash); + $hash->{STATE} = "disconnected"; + delete $hash->{INVALID_DEVICE} if defined($hash->{INVALID_DEVICE}); + + if ($hash->{AUTO_DETECT}) { + SmarterCoffee_RunDiscoveryProcess($hash, 1); + } + + if (defined($hash->{DeviceName})) { + if (not ($hash->{DeviceName} =~ m/^(.+):([0-9]+)$/)) { + $hash->{DeviceName} .= ":$SmarterCoffee_Port"; + } + + DevIo_CloseDev($hash) if DevIo_IsOpen($hash); + delete $hash->{DevIoJustClosed} if ($hash->{DevIoJustClosed}); + + SmarterCoffee_ReConnectTimer($hash); + return SmarterCoffee_OpenIfRequiredAndWritePending($hash, $isNewConnection); + } + return 0; +} + +sub SmarterCoffee_OpenIfRequiredAndWritePending($;$) { + my ($hash, $initial) = @_; + return DevIo_OpenDev($hash, ($initial ? 0 : 1), "SmarterCoffee_WritePending"); +} + +sub SmarterCoffee_HandleInitialConnectState($) { + my ($hash) = @_; + + return if ($hash->{".initial-connection-state"}); + + if (DevIo_IsOpen($hash) and ($hash->{STATE} eq "disconnected" or $hash->{STATE} eq "opened")) { + $hash->{".initial-connection-state"} = 1; + + $hash->{STATE} = "connected"; + SmarterCoffee_Get($hash, @{[ $hash->{NAME}, "info" ]}) if (not $hash->{AUTO_DETECT}); + SmarterCoffee_Get($hash, @{[ $hash->{NAME}, "carafe_required_status" ]}); + SmarterCoffee_Get($hash, @{[ $hash->{NAME}, "cups_single_mode_status" ]}); + + delete $hash->{".initial-connection-state"}; + } +} + +sub SmarterCoffee_WritePending { + my ($hash, $mustSucceed) = @_; + + if (DevIo_IsOpen($hash)) { + my $pending = ($hash->{PENDING_COMMAND} // 0); + + # Handling initial call on a fresh connection + SmarterCoffee_HandleInitialConnectState($hash); + + # Processing pending commands + if (($hash->{INVALID_DEVICE} // "0") eq "1") { + $hash->{STATE} = "invalid"; + } else { + if ($pending) { + delete $hash->{PENDING_COMMAND} if defined($hash->{PENDING_COMMAND}); + + Log3 $hash->{NAME}, 4, "Connection :: Sending to ".$hash->{DeviceName}.": $pending"; + DevIo_SimpleWrite($hash, $pending, 1); + $hash->{".raw_last_status"} = ""; + + my $result = DevIo_SimpleReadWithTimeout($hash, 5); + if ($result) { + $result = SmarterCoffee_Read($hash, $result); + } else { + DevIo_Disconnected($hash); + } + + $hash->{INVALID_DEVICE} = "1" if ($mustSucceed and not $result); + $hash->{PENDING_COMMAND} = $pending if (not $result); + } + } + } + + return undef; +} + +sub SmarterCoffee_Read($;$) { + my ($hash, $buffer) = @_; + + # Handle case that fhem reconnected a broken connection and state is "opened". + SmarterCoffee_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 = 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 (SmarterCoffee_ParseMessage($hash)) { + delete $hash->{PARTIAL}; + } else { + Log3 $hash->{NAME}, 2, "Connection :: Failed parsing buffer content: ".$hash->{PARTIAL}; + return 0; + } + } + + return 1; +} + +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->{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 " + .$readingFnAttributes; + + Log 5, "Initialized module 'SmarterCoffee'"; +} + +sub SmarterCoffee_Define($$) { + my ($hash, $def) = @_; + my @param = split('[ \t]+', $def); + my $name = $hash->{NAME}; + + # set default settings on first define + if ($init_done) { + $attr{$name}{alias} = "Coffee Machine"; + $attr{$name}{webCmd} = "strength:cups:start:hotplate:off"; + $attr{$name}{'strength-extra-percent'} = $SmarterCoffee_StrengthExtraDefaultPercent; + $attr{$name}{'default-hotplate-on-for-minutes'} = "15 5=20 8=30 10=35"; + $attr{$name}{'event-on-change-reading'} = ".*"; + $attr{$name}{'event-on-update-reading'} = "last_command.*"; + } + + $attr{$name}{devStateIcon} = '{ SmarterCoffee_GetDevStateIcon($name) }' if not defined($attr{$name}{devStateIcon}); + + if (int(@param) < 3) { + $hash->{AUTO_DETECT} = 1; + } else { + delete $hash->{AUTO_DETECT}; + $hash->{DeviceName} = $param[2]; + } + + $hash->{NOTIFYDEV} = "global,$name"; + $hash->{STATE} = "initializing"; + $hash->{devioLoglevel} = 4; + + $hash->{".last_command"} = + $hash->{".last_response"} = + $hash->{".last_status"} = + $hash->{".raw_last_status"} = ""; + + RemoveInternalTimer($hash); + + SmarterCoffee_Connect($hash); + + Log3 $hash->{NAME}, 4, "Instance :: Defined module 'SmarterCoffee': ".Dumper($hash); +} + + +sub SmarterCoffee_Undefine($$) { + my ($hash, $arg) = @_; + + RemoveInternalTimer($hash); + DevIo_CloseDev($hash); + + Log3 $hash->{NAME}, 4, "Instance :: Closed module 'SmarterCoffee': ".Dumper($hash); + + return undef; +} + +sub SmarterCoffee_Get { + my ($hash, @param) = @_; + + if (grep {$_ eq ($param[1] // "")} @SmarterCoffee_GetCommands) { + return SmarterCoffee_Set($hash, @param) // "Ok :: ".$hash->{".last_response"}; + } else { + return "Unknown argument $param[1], choose one of ".join(":noArg ", @SmarterCoffee_GetCommands).":noArg"; + } +} + +sub SmarterCoffee_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", $SmarterCoffee_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 = $SmarterCoffee_Hotplate{max} if ($value > $SmarterCoffee_Hotplate{max}); + $value = $SmarterCoffee_Hotplate{min} if ($value < $SmarterCoffee_Hotplate{min}); + + SmarterCoffee_UpdateReading($hash, "hotplate_on_for_minutes", ($option eq "hotplate_off" ? 0 : $value)); + + return unpack('H*', pack('C', $value)); + + } elsif (defined($SmarterCoffee_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 %{$SmarterCoffee_MessageMaps{$option}}) { + my $v = $SmarterCoffee_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") { + SmarterCoffee_Set($hash, @{[ $hash->{NAME}, "grinder", "enabled" ]}); + $grinderEnabled = 1; + $param[3] = "enabled" if defined($param[3]); + } + + if ($option ne "defaults" and $grinderEnabled) { + if (SmarterCoffee_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}; + + SmarterCoffee_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". + SmarterCoffee_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") { + SmarterCoffee_ResetState($hash); + } + + $messagePart = $optionToMessage->( $option, $param[0] ); + } + + # Command execution + if (defined($SmarterCoffee_Commands{$option})) { + my $message = $SmarterCoffee_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"}."]"; + + SmarterCoffee_WritePending($hash, ($option eq "info")); + } + return undef; + + } elsif ($option eq "disconnect" or $option eq "reconnect") { + # This option is primarily to test if reconnect works. + DevIo_Disconnected($hash); + SmarterCoffee_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 SmarterCoffee_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 SmarterCoffee_ResetState($) { + my ($hash) = @_; + + SmarterCoffee_ResetBrewState($hash); + SmarterCoffee_ResetExtraStrengthMode($hash); +} + +sub SmarterCoffee_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") { + SmarterCoffee_ReadConfiguration($hash) if (grep(m/^(INITIALIZED|REREADCFG)$/, @{$events})); + } else { + for (@{$events}) { + if ($_) { + SmarterCoffee_ProcessBrewStateEvents($hash, $_); + SmarterCoffee_ProcessEventForExtraStrength($hash, $_); + SmarterCoffee_LogCommands($hash, $_); + } + } + } + } +} + +sub SmarterCoffee_ReadConfiguration($) { + my ($hash) = @_; + + # Restoring extra strength + $hash->{".extra_strength.enabled"} = 1 if (ReadingsVal($hash->{NAME}, "strength", "") =~ /^extra.*/); +} + +sub SmarterCoffee_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}, 4, "Command :: Failed [$command]; Cause: $message"; + } + } +} + +sub SmarterCoffee_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/) { + SmarterCoffee_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."; + SmarterCoffee_ResetState($hash); + } +} + +sub SmarterCoffee_ResetBrewState($) { + my ($hash) = @_; + delete $hash->{".brew-state"} if defined($hash->{".brew-state"}); + delete $hash->{"INITIATED_BREWING"} if defined($hash->{"INITIATED_BREWING"}); +} + +sub SmarterCoffee_ProcessEventForExtraStrength($$) { + my ($hash, $event) = @_; + + if ($event =~ /^strength:\s*extra\s*$/) { + # Listen to "set strength extra" and enable it if available. + if (not (SmarterCoffee_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 SmarterCoffee_EnableExtraStrengthMode($hash) ) { + + Log3 $hash->{NAME}, 3, "Extra-Strength :: Upgrading brewing $cups cups started with disabled grinder and strength '$strength' to strength 'extra'."; + SmarterCoffee_Set($hash, @{[ $hash->{NAME}, "stop" ]}); + SmarterCoffee_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) { + SmarterCoffee_Set($hash, @{[ $hash->{NAME}, "cups", $hash->{".extra_strength.original_desired_cups"} ]}); + } + SmarterCoffee_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"} = SmarterCoffee_ExtraStrengthHandleBrewing($hash); + } + } +} + +sub SmarterCoffee_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 (SmarterCoffee_TranslateParamsForExtraStrength($hash, \@params, "brew")) { + # Resetting brew state to ensure it doesn't interfere with stop command that runs with "no-reset" option. + SmarterCoffee_ResetBrewState($hash); + + # Stopping brewing after initial grinding (skip stop if we are in phase-2 and came here due to pre-brew delay) + SmarterCoffee_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)."]"; + + SmarterCoffee_Set($hash, @params); + return 1; + } + + return 0; +} + +sub SmarterCoffee_IsExtraStrengthModeAvailable($;$) { + my ($hash, $slient) = @_; + + my $extraPercent = AttrVal($hash->{NAME}, "strength-extra-percent", $SmarterCoffee_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 SmarterCoffee_EnableExtraStrengthMode($) { + my ($hash) = @_; + + return 1 if ($hash->{".extra_strength.enabled"}); + + if (SmarterCoffee_IsExtraStrengthModeAvailable($hash, 0)) { + Log3 $hash->{NAME}, 4, "Extra-Strength :: Entering extra strength mode."; + $hash->{".extra_strength.enabled"} = 1; + return 1; + } else { + return 0; + } +} + +sub SmarterCoffee_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 SmarterCoffee_TranslateParamsForExtraStrength($$$) { + my ($hash, $params, $phase) = @_; + + return 0 if (not (SmarterCoffee_EnableExtraStrengthMode($hash))); + + if ($phase eq "grind") { + my $extraPercent = AttrVal($hash->{NAME}, "strength-extra-percent", $SmarterCoffee_StrengthExtraDefaultPercent); + + my @strengths = ("weak", "medium", "strong"); + my @weights = split(/\s+/, AttrVal($hash->{NAME}, "strength-coffee-weights", $SmarterCoffee_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"}; + SmarterCoffee_ResetExtraStrengthMode($hash, 1); + } + + return 1; + } + + return 0; +} + +sub SmarterCoffee_UpdateReading($$$) { + my ($hash, $name, $value) = @_; + SmarterCoffee_UpdateReadings($hash, sub($) { ($_[0])->( $name, $value ) }); +} + +sub SmarterCoffee_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 $hash->{STATE} ne $value); + }; + + readingsBeginUpdate($hash); + $callback->( $updater ); + readingsEndUpdate($hash, ($updated or $forceUpdate)); +} + +sub SmarterCoffee_RunDiscoveryProcess($;$) { + my ($hash, $skipConnect) = @_; + + if (SmarterCoffee_Discover($hash) and not $skipConnect) { + SmarterCoffee_Connect($hash); + } + + InternalTimer(gettimeofday() + $SmarterCoffee_DiscoveryInterval, "SmarterCoffee_RunDiscoveryProcess", $hash, 0); +} + +sub SmarterCoffee_InetSocketAddressString($) { + my ($port, $inetAddress) = sockaddr_in($_[0]); + return inet_ntoa($inetAddress).":$port" +} + +sub SmarterCoffee_Discover($) { + my ($hash) = @_; + + my $existingDeviceName = ($hash->{DeviceName} // ""); + my $broadcastAddress = sockaddr_in($SmarterCoffee_Port, INADDR_BROADCAST); + + Log3 $hash->{NAME}, 4, + "Discovery :: Broadcasting discovery request to ".SmarterCoffee_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 = SmarterCoffee_InetSocketAddressString($deviceAddress); + + $message = unpack('H*', $message); + + Log3 $hash->{NAME}, 4, "Discovery :: Received message $message from $inetSocketAddress"; + + if ($message =~ /^65.*7e.*/ and SmarterCoffee_ParseMessage($hash, $message)) { + my ($port, $inetAddress) = sockaddr_in($deviceAddress); + + if (my ($hostname) = gethostbyaddr($inetAddress, AF_INET)) { + $hash->{DeviceName} = $hostname.":$port"; + } 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 SmarterCoffee_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;
+}
+
+sub SmarterCoffee_ReConnectTimer($) {
+
+    my $hash    = shift;
+    
+    
+    if( defined($hash->{FD}) ) {
+        DevIo_Disconnected($hash);
+        DevIo_OpenDev($hash, 1, undef);
+        Log3 $hash->{NAME}, 4, "SmarterCoffee_ReConnectTimer - Socket Reconnected";
+    }
+    
+    InternalTimer(gettimeofday() + 480, "SmarterCoffee_ReConnectTimer", $hash);
+    Log3 $hash->{NAME}, 4, "SmarterCoffee_ReConnectTimer - Call InternalTimer";
+}
+
+## -------------------------------------------------------------------------------------------------------------------
+## 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