mirror of
https://github.com/fhem/fhem-mirror.git
synced 2024-11-22 09:49:50 +00:00
fb017072e5
git-svn-id: https://svn.fhem.de/fhem/trunk@21021 2b470e98-0d58-463d-a4d8-8e2adae1ed80
2028 lines
79 KiB
Perl
2028 lines
79 KiB
Perl
#############################################################
|
|
#
|
|
# 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 <name> off"
|
|
$option = "stop" if ( $option =~ /^off$/i );
|
|
|
|
# Support "set <name> on" and "set <name> 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 <IP address or hostname> in fhem config at: "
|
|
. "'define "
|
|
. $hash->{NAME}
|
|
. " SmarterCoffee <IP address or hostname>' 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;
|
|
<svg class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewbox="0,0,340,300">
|
|
<g id="Layer1" name="water-level-25" opacity="1">
|
|
<path id="shapePath1" d="M10.7664,202.592 L39.25,202.592 L39.25,251.046 L10.7664,251.046 L10.7664,202.592 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:0;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill-rule:evenodd;fill:#000000;fill-opacity:0.501961;"/>
|
|
</g>
|
|
<g id="Layer2" name="water-level-50" opacity="1">
|
|
<path id="shapePath2" d="M10.7664,148.916 L39.25,148.916 L39.25,251.046 L10.7664,251.046 L10.7664,148.916 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:0;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill-rule:evenodd;fill:#000000;fill-opacity:0.501961;"/>
|
|
</g>
|
|
<g id="Layer3" name="water-level-100" opacity="1">
|
|
<path id="shapePath3" d="M10.7664,72.7703 L39.25,72.7703 L39.25,251.046 L10.7664,251.046 L10.7664,72.7703 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:0;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill-rule:evenodd;fill:#000000;fill-opacity:0.501961;"/>
|
|
</g>
|
|
<g id="Layer4" name="coffee-level" opacity="1">
|
|
<path id="shapePath4"
|
|
d="M105.839,186.865 C110.989,185.046 119.097,178.364 126.436,179.589 C135.267,181.064 134.469,186.36 143.482,186.592 C151.348,186.796 156.278,181.403 164.079,180.563 C173.196,179.579 180.365,188.579 189.536,189.158 C196.588,189.602 208.142,183.427 215.216,183.427 C224.061,183.427 226.257,189.158 235.103,189.158 C241.477,189.158 248.419,184.86 252.858,183.427 "
|
|
style="stroke:#000000;stroke-opacity:0.776471;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath5" d="M119.439,200.823 L121.792,200.823 C127.621,198.887 129.259,210.763 130.009,202.074 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath6" d="M164.019,197.487 C166.354,198.01 171.011,196.425 171.832,198.321 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath7" d="M221.007,204.159 C223.591,204.456 227.478,203.571 229.28,204.576 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath8" d="M210.437,222.922 L221.467,222.922 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath9" d="M230.659,241.269 C226.311,241.752 216.061,240.957 216.411,238.35 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath10" d="M120.358,235.432 C123.681,235.753 128.403,234.804 130.928,235.849 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath11" d="M158.963,226.548 L170.913,226.548 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath12" d="M192.513,232.513 Z"
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath13" d="M200.326,196.236 C202.87,195.729 207.125,195.933 208.139,196.236 "
|
|
style="stroke:#000000;stroke-opacity:0.509804;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
<g id="Layer5" name="machine" opacity="1">
|
|
<path id="shapePath14"
|
|
d="M10.7795,288.06 L309.337,288.06 L309.337,287.83 L63.516,287.83 L63.516,38.3877 L277.337,38.3877 L291.337,10.509 L10.7795,10.509 "
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:13.5;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath15"
|
|
d="M10.1377,84.2715 C10.1377,75.9847 16.8555,69.2668 25.1423,69.2668 L25.7807,69.2668 C34.0675,69.2668 40.7853,75.9847 40.7853,84.2715 L40.1469,239.658 C40.1469,247.944 33.4291,254.663 25.1423,254.663 L24.504,254.663 C16.2172,254.663 9.4994,247.944 9.4994,239.658 L10.1377,84.2715 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:8;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
<g id="Layer6" name="carafe" opacity="1">
|
|
<path id="shapePath16"
|
|
d="M82.5,70.5 L281.5,70.5 L332.5,230.122 L305.207,230.122 L270.5,122.968 L253.5,122.967 L253.5,252.5 L106.5,252.5 L106.5,122.968 L82.5,70.5 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:11;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
<g id="Layer7" name="heating" opacity="1">
|
|
<path id="shapePath17" d="M154.14,171.887 C116.542,221.403 178.086,221.403 144.759,270.919 "
|
|
style="stroke:#000000;stroke-opacity:0.823529;stroke-width:20.5;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath18" d="M188.668,171.887 C151.071,221.403 212.613,221.403 179.286,270.919 "
|
|
style="stroke:#000000;stroke-opacity:0.823529;stroke-width:20.5;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath19" d="M221.858,171.887 C184.261,221.403 245.803,221.403 212.476,270.919 "
|
|
style="stroke:#000000;stroke-opacity:0.823529;stroke-width:20.5;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
<g id="Layer8" name="brewing" opacity="1">
|
|
<path id="shapePath20" d="M183.349,41.4504 L183.349,135.501 "
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:16;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;stroke-dasharray:48,32;fill:none;"/>
|
|
</g>
|
|
<g id="Layer9" name="ready" opacity="1">
|
|
<path id="shapePath21" d="M310.567,47.2444 L210.027,187.123 L166,146 "
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:23;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
<g id="Layer10" name="no-water" opacity="0">
|
|
<path id="shapePath22"
|
|
d="M122.5,155 C122.5,113.855 155.855,80.5 197,80.5 C238.145,80.5 271.5,113.855 271.5,155 C271.5,196.145 238.145,229.5 197,229.5 C155.855,229.5 122.5,196.145 122.5,155 Z"
|
|
style="stroke:#000000;stroke-opacity:0.47451;stroke-width:20;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath23" d="M147.412,195.101 C147.412,195.101 247.103,114.482 247.103,114.482 "
|
|
style="stroke:#000000;stroke-opacity:0.47451;stroke-width:20;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
<path id="shapePath24"
|
|
d="M196.525,98.5 L201.136,118.997 C204.122,137.531 227.896,146 227.896,172.094 C227.896,221.827 165.155,221.227 165.155,172.094 C165.155,145.791 187.942,137.596 192.007,118.997 L196.525,98.5 Z"
|
|
style="stroke:#000000;stroke-opacity:1;stroke-width:15;stroke-linejoin:miter;stroke-miterlimit:2;stroke-linecap:round;fill:none;"/>
|
|
</g>
|
|
</svg>
|
|
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 <pre> 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
|
|
|
|
<a name="SmarterCoffee"></a>
|
|
<h3>SmarterCoffee</h3>
|
|
<ul>
|
|
Integrates the equally called Wi-Fi coffee machine (<code>http://smarter.am/</code>) with FHEM.
|
|
<br><br>
|
|
<i>Prerequisite</i>:<br>
|
|
Make sure the machine can be controlled by the smarter mobile app when both are connected to the same network as fhem.<br>
|
|
If in doubt check the official documentation or official support forum to get help with integrating the coffee machine into your network.
|
|
</ul>
|
|
<br>
|
|
|
|
<a name="SmarterCoffeedefine"></a>
|
|
<b>Define</b>
|
|
<ul>
|
|
<code>define <name> SmarterCoffee (<hostname>)</code>
|
|
<br><br>
|
|
Hostname is optional, if omitted the name is auto-detected via UDP broadcast.
|
|
<br><br>
|
|
Examples:<ul>
|
|
<li><code>define coffee-machine SmarterCoffee</code><br>
|
|
Connects with the first coffee machine that answers the UDP broadcast.</li><br>
|
|
<li><code>define coffee-machine SmarterCoffee smarter-coffee.fritz.box</code><br>
|
|
Connects with the coffee machine at address 'smarter-coffee.fritz.box'.</li><br>
|
|
<li><code>define coffee-machine SmarterCoffee 192.168.2.56:2081</code><br>
|
|
Connects with the coffee machine at '192.168.2.56' using port 2081 (= default)</li>
|
|
</ul>
|
|
</ul>
|
|
<br>
|
|
|
|
<a name="SmarterCoffeereadings"></a>
|
|
<b>Readings</b><br>
|
|
<ul>
|
|
<li>
|
|
<code>state</code><br>
|
|
Device state, can be one of:<ul>
|
|
<li><code>disconnected</code>: No connection to coffee machine.</li>
|
|
<li><code>opened / connected</code>: Intermediate states after connection has been established but before the machine's state is known.</li>
|
|
<li><code>invalid</code>: The connected device is not a coffee machine.</li>
|
|
<li><code>ready</code>: Ready to start brewing.</li>
|
|
<li><code>grinding</code>: Grinding coffee.</li>
|
|
<li><code>brewing</code>: Brewing coffee.</li>
|
|
<li><code>done</code>: Done brewing.</li>
|
|
<li><code>heating</code>: Keeping coffee warm or reheating.</li>
|
|
<li><code>maintenance</code>: Maintenance is needed to get ready for brewing (e.g. water or carafe is missing).</li>
|
|
</ul>
|
|
</li><br>
|
|
<li>
|
|
<code>hotplate_on_for_minutes</code><br>
|
|
Shows the number of minutes that the hotplate will be on when it was turned on via "<code>set <name> hotplate</code>"
|
|
or "<code>set <name> brew</code>".</li><br>
|
|
<li>
|
|
<code>carafe</code><br>
|
|
One of "<code>present</code>" or "<code>missing</code>" as the carafe is detected as being present or not.<br>
|
|
(<code>state</code> <code>ready</code> turns to <code>maintenance</code> when carafe is missing and <code>carafe_required</code> is <code>yes</code>)</li><br>
|
|
<li>
|
|
<code>carafe_required</code><br>
|
|
Is "<code>yes</code>" or "<code>no</code>" as the carafe is required to start brewing or not.<br>
|
|
This option can be configured via the smarter mobile app. Read disclaimer before turning off carafe detection.</li><br>
|
|
<li>
|
|
<code>cups_max</code><br>
|
|
The estimated maximum brewable cups when taking current water level into account.</li><br>
|
|
<li>
|
|
<code>cups_single_mode</code><br>
|
|
Is "<code>yes</code>" or "<code>no</code>" as the single cup mode is active or not.<br>
|
|
This option can be configured via the smarter mobile app.
|
|
When enabled "<code>set <name> brew</code>" will not enable the hotplate and "<code>cups_max</code>" is limited to <code>3</code>.
|
|
In single cup mode, cups [1,2,3] is used for one [small,medium,large] cup.</li><br>
|
|
<li>
|
|
<code>water</code> and <code>water_level</code><br>
|
|
Is [<code>none, low, half, full</code>] and [<code>0, 25, 50, 100</code>] indicating the amount of water that remains in the tank.<br>
|
|
(<code>state</code> <code>ready</code> turns to <code>maintenance</code> when <code>water_level</code> is "<code>0</code>")</li><br>
|
|
<li>
|
|
<code>last_command.*</code><br>
|
|
Is updated with the last executed <code>set</code> or <code>get</code> command string, including device response and success information.</li><br>
|
|
<li>
|
|
Further readings match <code>set</code> commands and reflect the corresponding machine state. See <i>Set</i> section below.</li><br>
|
|
</ul>
|
|
|
|
<a name="SmarterCoffeeget"></a>
|
|
<b>Get</b><br>
|
|
<ul>
|
|
<li>
|
|
<code>get <name> info</code><br>
|
|
Retrieves firmware & device type information and updates internals.</li><br>
|
|
<li>
|
|
<code>get <name> carafe_required_status</code><br>
|
|
Retrieves whether carafe is required for brewing and updates reading "carafe_required".</li><br>
|
|
<li>
|
|
<code>get <name> cups_single_mode_status</code><br>
|
|
Retrieves whether single cup mode is active and updates reading "cups_single_mode".</li><br>
|
|
<li>
|
|
<code>get <name> get_defaults</code><br>
|
|
Retrieves and applies previously set machine defaults. Triggers the event "defaults" after retrieval but before applying them.</li><br>
|
|
</ul>
|
|
|
|
<a name="SmarterCoffeeset"></a>
|
|
<b>Set</b><br>
|
|
<ul>
|
|
<li>
|
|
<code>set <name> brew</code><br>
|
|
Start brewing with settings displayed in readings and "<code>default-hotplate-on-for-minutes</code>" for hotplate.</li>
|
|
<li>
|
|
<code>set <name> brew current</code><br>
|
|
Start brewing with current machine settings.</li>
|
|
<li>
|
|
<code>set <name> brew [1 - 12] ([weak, medium, strong, extra]) ([5-40]) ([enabled, disabled])</code><br>
|
|
Start brewing the specified amount of cups at optionally specified strength, hotplate and grinder with non-specified settings used from readings
|
|
and "<code>default-hotplate-on-for-minutes</code>" for hotplate.
|
|
<p></p>
|
|
E.g. "<code>set <name> brew 5 medium</code>" brews 5 cups of medium coffee reusing current hotplate and grinder settings.</li><br>
|
|
<li>
|
|
<code>set <name> defaults ([1 - 12]) ([weak, medium, strong]) ([5-40]) ([enabled, disabled])</code><br>
|
|
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 "<code>default-hotplate-on-for-minutes</code>" for hotplate.<br>
|
|
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.
|
|
<p></p>
|
|
E.g. "<code>set <name> defaults 5 medium</code>" sets defaults to 5 cups of medium coffee and reuses current hotplate and grinder settings
|
|
as future defaults.</li><br>
|
|
<li>
|
|
<code>set <name> stop</code><br>
|
|
Stop brewing and disable hotplate if on.</li><br>
|
|
<li>
|
|
<code>set <name> strength [weak, medium, strong, extra]</code><br>
|
|
Toggles the strength via the amount of coffee beans to use per cup when grinding.
|
|
<p></p>
|
|
The strength "<code>extra</code>" 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:<br>
|
|
<code>set <name> brew <cups + 1> strong on enabled ;; sleep <cups * 1.5> ;; set <name> stop ;; set <name> brew <cups> on disabled</code>.<br>
|
|
See also attributes "<code>strength-extra-percent</code>" and "<code>strength-coffee-weights</code>" which control the calculation
|
|
of actual strength and cup counts.</li><br>
|
|
<li>
|
|
<code>set <name> grinder [enabled, disabled]</code><br>
|
|
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.</li><br>
|
|
<li>
|
|
<code>set <name> cups [1 - 12]</code><br>
|
|
Toggles the amount of cups (~100ml) to brew.</li><br>
|
|
<li>
|
|
<code>set <name> [1 - 12]-cups</code><br>
|
|
Is an alias to "<code>set <name> brew [1 - 12]</code>".
|
|
This adds support for web commands like "8-cups" and "3-cups,strong".</li><br>
|
|
<li>
|
|
<code>set <name> hotplate <command></code><br>
|
|
Toggles the hotplate that keeps the coffee warm after brewing.
|
|
<br><br>
|
|
<command> is one of:<ul>
|
|
<li><code>on</code><br>On for "<code>default-hotplate-on-for-minutes</code>" minutes (defaults to 15 minutes)</li>
|
|
<li><code>on [5 - 40]</code><br>On for the specified amount of minutes</li>
|
|
<li><code>off</code></li>
|
|
</ul>
|
|
</li><br>
|
|
<li>
|
|
<code>set <name> hotplate_on_for_minutes [5 - 40]</code><br>
|
|
Is an alias to "<code>set <name> hotplate on [5 - 40]</code>".</li><br>
|
|
<li>
|
|
<code>set <name> reconnect</code><br>
|
|
Disconnects, optionally runs discovery (if hostname or address was omitted in the device definition) and reconnects.</li><br>
|
|
<li>
|
|
<code>set <name> reset</code><br>
|
|
Resets machine to factory default, excluding WLAN settings.</li><br>
|
|
</ul>
|
|
|
|
<a name="SmarterCoffeeattr"></a>
|
|
<b>Attributes</b><br>
|
|
<ul>
|
|
<li>
|
|
<code>attr <name> devStateIcon { SmarterCoffee::GetDevStateIcon($name) }</code>
|
|
<br><br>
|
|
The function <code>SmarterCoffee::GetDevStateIcon($name[, "...colors..."])</code> renders a custom dev state icon that displays
|
|
the machine states (ready, brewing, done) and shows information on carafe, hotplate and water level.
|
|
<br><br>
|
|
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 <code>SmarterCoffee::GetDevStateIcon</code>.<br>
|
|
E.g. using "<code>attr <name> devStateIcon { SmarterCoffee::GetDevStateIcon($name, '#7b7b7b green chocolate #336699' }</code>"
|
|
sets colors for default, ready, brewing and done.
|
|
<br><br>
|
|
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.</li><br>
|
|
<li>
|
|
<code>attr <name> default-hotplate-on-for-minutes 15</code><br>
|
|
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.<br>
|
|
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).
|
|
<br><br>
|
|
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 "<code>15 5=20 10=35</code>" means: 15 from 1 to 4 cups, 20 from 5 cups and 35 from 10 cups.</li><br>
|
|
<li>
|
|
<code>attr <name> ignore-max-cups [1, 0]</code><br>
|
|
Toggles whether the reading "<code>cups_max</code>" influences the state "<code>ready</code>".
|
|
By default "<code>attr <name> ignore-max-cups 1</code>" is assumed which means that the state "<code>ready</code>" is not
|
|
related to "<code>cups_max</code>" if this attribute is not set. Set this attribute to "0" if the state should turn to "<code>maintenance</code>"
|
|
when the selected cup count is larger than "<code>cups_max</code>".</li><br>
|
|
<li>
|
|
<code>attr <name> set-on-brews-coffee [0, 1]</code><br>
|
|
Toggles whether the command "<code>set <name> on</code>" is an alias to "<code>set <name> brew</code>".
|
|
By default this is disabled to avoid accidental coffee brewing.</li><br>
|
|
<li>
|
|
<code>attr <name> strength-extra-percent 1.4</code><br>
|
|
Specifies the percentage of coffee to use relative to strength "<code>strong</code>" when brewing coffee with <code>extra</code> strength.
|
|
A value of "<code>1.4</code>" brews coffee that is 140% the strength of "<code>strong</code>" respectively "<code>0.6</code>" brews coffee that
|
|
is 60% the strength. Setting <code>strength-extra-percent</code> to <code>0</code> disables support for extra strength.
|
|
<br><br>
|
|
Note: Brewing coffee with <code>extra</code> 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.</li><br>
|
|
<li>
|
|
<code>attr <name> strength-extra-pre-brew-delay-seconds 0</code><br>
|
|
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".
|
|
</li><br>
|
|
<li>
|
|
<code>attr <name> strength-extra-pre-brew-cups 1</code><br>
|
|
Specifies the number of cups that are brewed first before delaying brewing in extra mode. Specifying 0 disables "pre-brew".
|
|
</li><br>
|
|
<li>
|
|
<code>attr <name> strength-extra-start-on-device-strength [off, weak, medium, strong]</code><br>
|
|
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 "<code>off</code>" which means that strength 'extra' can only be used when starting brewing via FHEM.
|
|
<br>
|
|
E.g. a value of "<code>weak</code>" 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).
|
|
<br><br>
|
|
Note: Brewing started from FHEM is never affected by this setting.
|
|
</li><br>
|
|
<li>
|
|
<code>attr <name> strength-coffee-weights 3.5 3.9 4.3</code><br>
|
|
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.<br>
|
|
The amounts are specified in grams per strength <code>[weak, medium, strong]</code> using whitespace as delimiter.
|
|
<br><br>
|
|
The purpose of this metric is to calculate the actual <code>strength</code> and <code>cups</code> to use when grinding coffee with
|
|
<code>extra</code> strength. E.g. for 140% extra strength, 4 cups require <tt style="white-space: nowrap">(4 * 4.3 * 1.4) = 24.08</tt>
|
|
gramms of coffee. In this example the closest match is grinding 7 cups with weak strength which produces <code>(7 * 3.5) = 24.5</code> gramms.
|
|
The actual brewing is then performed with 4 cups as originally requested.
|
|
<br><br>
|
|
The algorithm tries to find the closest matching cup counts and strength value towards the target amount of coffee required for
|
|
<code>extra</code> 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 <code>weak, medium, strong</code> 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.
|
|
<br><br>
|
|
Note: SCAE (Speciality Coffee Association of Europe) recommends ~6 gramms of coffee per cup. To come close, 140% is the default value for
|
|
<code>extra</code> strength assuming that the default values for <code>strength-coffee-weights</code> 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 (<code>strength-coffee-weights</code> and <code>strength-extra-percent</code>)
|
|
accordingly.
|
|
</li><br>
|
|
</ul>
|
|
|
|
=end html
|
|
|
|
=cut
|