############################################################################## # # 88_HMCCU.pm # # $Id$ # # Version 4.2.008 # # Module for communication between FHEM and Homematic CCU2. # # Supports BidCos-RF, BidCos-Wired, HmIP-RF, virtual CCU channels, # CCU group devices, HomeGear, CUxD, Osram Lightify, Homematic Virtual Layer # and Philips Hue (not tested) # # (c) 2018 by zap (zap01 t-online de) # ############################################################################## # # define HMCCU [ccunumber] [waitforccu=] # # set ackmessages # set cleardefaults # set defaults # set delete [{ OT_VARDP | OT_DEVICE }] # set execute # set importdefaults # set hmscript {|!|'['']'} [dump] [= [...]] # set rpcserver {on|off|restart} # set var [] [= [...]] # # get aggregation {|all} # get configdesc {|} # get defaults # get deviceinfo # get devicelist [dump] # get devicelist create [t={chn|dev|all}] [s=] [p=] [f=] # [defattr] [duplicates] [save] [= [...]]}] # get dump {devtypes|datapoints} [] # get dutycycle # get exportdefaults {filename} # get firmware [{type-expr}|full] # get parfile [] # get rpcevents # get rpcstate # get update [ [{ State | Value }]] # get updateccu [ [{ State | Value }]] # get vars # # attr ccuaggregate # attr ccudef-hmstatevals # attr ccudef-readingfilter # attr ccudef-readingname # attr ccudef-substitute # attr ccudefaults # attr ccuflags { intrpc,extrpc,procrpc,dptnocheck,noagg,logEvents,noReadings,nonBlocking } # attr ccuget { State | Value } # attr ccuReqTimeout # attr parfile # attr rpcevtimeout # attr rpcinterfaces { BidCos-Wired, BidCos-RF, HmIP-RF, VirtualDevices, Homegear, HVL } # attr rpcinterval # attr rpcport # attr rpcqueue # attr rpcserver { on | off } # attr rpcserveraddr # attr rpcserverport # attr rpctimeout [,] # attr stripchar # attr stripnumber [|0|1|2}[;...] # attr substitute # # filter_rule := channel-regexp!datapoint-regexp[;...] # subst_rule := [[channel.]datapoint[,...]!]:[,...][;...] ############################################################################## # Verbose levels: # # 0 = Log start/stop and initialization messages # 1 = Log errors # 2 = Log counters and warnings # 3 = Log events and runtime information ############################################################################## package main; no if $] >= 5.017011, warnings => 'experimental::smartmatch'; use strict; use warnings; # use Data::Dumper; # use Time::HiRes qw(usleep); use IO::File; use Fcntl 'SEEK_END', 'SEEK_SET', 'O_CREAT', 'O_RDWR'; use RPC::XML::Client; use RPC::XML::Server; use SetExtensions; use SubProcess; use HMCCUConf; # Import configuration data my $HMCCU_CHN_DEFAULTS = \%HMCCUConf::HMCCU_CHN_DEFAULTS; my $HMCCU_DEV_DEFAULTS = \%HMCCUConf::HMCCU_DEV_DEFAULTS; my $HMCCU_SCRIPTS = \%HMCCUConf::HMCCU_SCRIPTS; # Custom configuration data my %HMCCU_CUST_CHN_DEFAULTS; my %HMCCU_CUST_DEV_DEFAULTS; # HMCCU version my $HMCCU_VERSION = '4.2.008'; # Default RPC port (BidCos-RF) my $HMCCU_RPC_PORT_DEFAULT = 2001; my $HMCCU_RPC_INTERFACE_DEFAULT = 'BidCos-RF'; # Constants and default values my $HMCCU_MAX_IOERRORS = 100; my $HMCCU_MAX_QUEUESIZE = 500; my $HMCCU_TIME_WAIT = 100000; my $HMCCU_TIME_TRIGGER = 10; my $HMCCU_TIMEOUT_CONNECTION = 10; my $HMCCU_TIMEOUT_WRITE = 0.001; my $HMCCU_TIMEOUT_ACCEPT = 1; my $HMCCU_TIMEOUT_EVENT = 600; my $HMCCU_STATISTICS = 500; my $HMCCU_TIMEOUT_REQUEST = 4; # RPC port name by port number my %HMCCU_RPC_NUMPORT = ( 2000 => 'BidCos-Wired', 2001 => 'BidCos-RF', 2010 => 'HmIP-RF', 9292 => 'VirtualDevices', 2003 => 'Homegear', 8701 => 'CUxD', 7000 => 'HVL' ); # RPC port number by port name my %HMCCU_RPC_PORT = ( 'BidCos-Wired', 2000, 'BidCos-RF', 2001, 'HmIP-RF', 2010, 'VirtualDevices', 9292, 'Homegear', 2003, 'CUxD', 8701, 'HVL', 7000 ); # RPC flags my %HMCCU_RPC_FLAG = ( 2000 => 'forceASCII', 2001 => 'forceASCII', 2003 => '_', 2010 => '_', 7000 => 'forceInit', 8701 => 'forceInit', 9292 => '_' ); # Initial intervals for registration of RPC callbacks and reading RPC queue # # X = Start RPC server # X+HMCCU_INIT_INTERVAL1 = Register RPC callback # X+HMCCU_INIT_INTERVAL2 = Read RPC Queue # my $HMCCU_INIT_INTERVAL0 = 12; my $HMCCU_INIT_INTERVAL1 = 7; my $HMCCU_INIT_INTERVAL2 = 5; # Number of arguments in RPC events my %rpceventargs = ( "EV", 3, "ND", 6, "DD", 1, "RD", 2, "RA", 1, "UD", 2, "IN", 3, "EX", 3, "SL", 2, "ST", 10 ); # Datapoint operations my $HMCCU_OPER_READ = 1; my $HMCCU_OPER_WRITE = 2; my $HMCCU_OPER_EVENT = 4; # Datapoint types my $HMCCU_TYPE_BINARY = 2; my $HMCCU_TYPE_FLOAT = 4; my $HMCCU_TYPE_INTEGER = 16; my $HMCCU_TYPE_STRING = 20; # Flags for CCU object specification my $HMCCU_FLAG_NAME = 1; my $HMCCU_FLAG_CHANNEL = 2; my $HMCCU_FLAG_DATAPOINT = 4; my $HMCCU_FLAG_ADDRESS = 8; my $HMCCU_FLAG_INTERFACE = 16; my $HMCCU_FLAG_FULLADDR = 32; # Valid flag combinations my $HMCCU_FLAGS_IACD = $HMCCU_FLAG_INTERFACE | $HMCCU_FLAG_ADDRESS | $HMCCU_FLAG_CHANNEL | $HMCCU_FLAG_DATAPOINT; my $HMCCU_FLAGS_IAC = $HMCCU_FLAG_INTERFACE | $HMCCU_FLAG_ADDRESS | $HMCCU_FLAG_CHANNEL; my $HMCCU_FLAGS_ACD = $HMCCU_FLAG_ADDRESS | $HMCCU_FLAG_CHANNEL | $HMCCU_FLAG_DATAPOINT; my $HMCCU_FLAGS_AC = $HMCCU_FLAG_ADDRESS | $HMCCU_FLAG_CHANNEL; my $HMCCU_FLAGS_ND = $HMCCU_FLAG_NAME | $HMCCU_FLAG_DATAPOINT; my $HMCCU_FLAGS_NC = $HMCCU_FLAG_NAME | $HMCCU_FLAG_CHANNEL; my $HMCCU_FLAGS_NCD = $HMCCU_FLAG_NAME | $HMCCU_FLAG_CHANNEL | $HMCCU_FLAG_DATAPOINT; # Flags for address/name checks my $HMCCU_FL_STADDRESS = 1; my $HMCCU_FL_NAME = 2; my $HMCCU_FL_EXADDRESS = 4; my $HMCCU_FL_ADDRESS = 5; my $HMCCU_FL_ALL = 7; # Default values my $HMCCU_DEF_HMSTATE = '^0\.UNREACH!(1|true):unreachable;^[0-9]\.LOW_?BAT!(1|true):warn_battery'; # Placeholder for external addresses (i.e. HVL) my $HMCCU_EXT_ADDR = 'ZZZ0000000'; # Binary RPC data types my $BINRPC_INTEGER = 1; my $BINRPC_BOOL = 2; my $BINRPC_STRING = 3; my $BINRPC_DOUBLE = 4; my $BINRPC_BASE64 = 17; my $BINRPC_ARRAY = 256; my $BINRPC_STRUCT = 257; # Declare functions sub HMCCU_Initialize ($); sub HMCCU_Define ($$); sub HMCCU_Undef ($$); sub HMCCU_Shutdown ($); sub HMCCU_Set ($@); sub HMCCU_Get ($@); sub HMCCU_Attr ($@); sub HMCCU_AggregationRules ($$); sub HMCCU_ExportDefaults ($); sub HMCCU_ImportDefaults ($); sub HMCCU_FindDefaults ($$); sub HMCCU_SetDefaults ($); sub HMCCU_GetDefaults ($$); sub HMCCU_Notify ($$); sub HMCCU_AggregateReadings ($$); sub HMCCU_ParseObject ($$$); sub HMCCU_FilterReading ($$$); sub HMCCU_GetReadingName ($$$$$$$); sub HMCCU_FormatReadingValue ($$$); sub HMCCU_Trace ($$$$); sub HMCCU_Log ($$$$); sub HMCCU_SetError ($@); sub HMCCU_SetState ($@); sub HMCCU_SetRPCState ($@); sub HMCCU_Substitute ($$$$$); sub HMCCU_SubstRule ($$$); sub HMCCU_SubstVariables ($$$); sub HMCCU_UpdateClients ($$$$$); sub HMCCU_UpdateDeviceTable ($$); sub HMCCU_UpdateSingleDatapoint ($$$$); sub HMCCU_UpdateSingleDevice ($$$); sub HMCCU_UpdateMultipleDevices ($$); sub HMCCU_UpdatePeers ($$$$); sub HMCCU_GetRPCInterfaceList ($); sub HMCCU_GetRPCPortList ($); sub HMCCU_GetRPCCallbackURL ($$$$$); sub HMCCU_GetRPCServerInfo ($$$); sub HMCCU_IsRPCType ($$$); sub HMCCU_RPCRegisterCallback ($); sub HMCCU_RPCDeRegisterCallback ($); sub HMCCU_ResetCounters ($); sub HMCCU_StartExtRPCServer ($); sub HMCCU_StopExtRPCServer ($); sub HMCCU_StartIntRPCServer ($); sub HMCCU_StopRPCServer ($); sub HMCCU_IsRPCStateBlocking ($); sub HMCCU_IsRPCServerRunning ($$$); sub HMCCU_GetDeviceInfo ($$$); sub HMCCU_FormatDeviceInfo ($); sub HMCCU_GetFirmwareVersions ($$); sub HMCCU_GetDeviceList ($); sub HMCCU_GetDatapointList ($$$); sub HMCCU_FindDatapoint ($$$$$); sub HMCCU_GetAddress ($$$$); sub HMCCU_IsDevAddr ($$); sub HMCCU_IsChnAddr ($$); sub HMCCU_SplitChnAddr ($); sub HMCCU_FindClientDevices ($$$$); sub HMCCU_GetRPCDevice ($$$); sub HMCCU_FindIODevice ($); sub HMCCU_GetHash ($@); sub HMCCU_GetAttribute ($$$$); sub HMCCU_GetDatapointCount ($$$); sub HMCCU_GetSpecialDatapoints ($$$$$); sub HMCCU_GetAttrReadingFormat ($$); sub HMCCU_GetAttrSubstitute ($$); sub HMCCU_IsValidDeviceOrChannel ($$$); sub HMCCU_IsValidDevice ($$$); sub HMCCU_IsValidChannel ($$$); sub HMCCU_GetCCUDeviceParam ($$); sub HMCCU_GetDatapointAttr ($$$$$); sub HMCCU_GetValidDatapoints ($$$$$); sub HMCCU_GetSwitchDatapoint ($$$); sub HMCCU_IsValidDatapoint ($$$$$); sub HMCCU_GetMatchingDevices ($$$$); sub HMCCU_GetDeviceName ($$$); sub HMCCU_GetChannelName ($$$); sub HMCCU_GetDeviceType ($$$); sub HMCCU_GetDeviceChannels ($$$); sub HMCCU_GetDeviceInterface ($$$); sub HMCCU_ResetRPCQueue ($$); sub HMCCU_ReadRPCQueue ($); sub HMCCU_ProcessEvent ($$); sub HMCCU_HMCommand ($$$); sub HMCCU_HMCommandNB ($$$); sub HMCCU_HMCommandCB ($$$); sub HMCCU_HMScriptExt ($$$); sub HMCCU_BulkUpdate ($$$$); sub HMCCU_GetDatapoint ($@); sub HMCCU_SetDatapoint ($$$); sub HMCCU_ScaleValue ($$$$$); sub HMCCU_GetVariables ($$); sub HMCCU_SetVariable ($$$$$); sub HMCCU_GetUpdate ($$$); sub HMCCU_GetChannel ($$); sub HMCCU_RPCGetConfig ($$$$); sub HMCCU_RPCSetConfig ($$$); # File queue functions sub HMCCU_QueueOpen ($$); sub HMCCU_QueueClose ($); sub HMCCU_QueueReset ($); sub HMCCU_QueueEnq ($$); sub HMCCU_QueueDeq ($); # Helper functions sub HMCCU_GetHMState ($$$); sub HMCCU_GetTimeSpec ($); sub HMCCU_CalculateReading ($$$); sub HMCCU_EncodeEPDisplay ($); sub HMCCU_RefToString ($); sub HMCCU_ExprMatch ($$$); sub HMCCU_ExprNotMatch ($$$); sub HMCCU_GetDutyCycle ($); sub HMCCU_TCPPing ($$$); sub HMCCU_TCPConnect ($$); sub HMCCU_ResolveName ($$); sub HMCCU_CorrectName ($); # Subprocess functions sub HMCCU_CCURPC_Write ($$); sub HMCCU_CCURPC_OnRun ($); sub HMCCU_CCURPC_OnExit (); sub HMCCU_CCURPC_NewDevicesCB ($$$); sub HMCCU_CCURPC_DeleteDevicesCB ($$$); sub HMCCU_CCURPC_UpdateDeviceCB ($$$$); sub HMCCU_CCURPC_ReplaceDeviceCB ($$$$); sub HMCCU_CCURPC_ReaddDevicesCB ($$$); sub HMCCU_CCURPC_EventCB ($$$$$); sub HMCCU_CCURPC_ListDevicesCB ($$); ################################################## # Initialize module ################################################## sub HMCCU_Initialize ($) { my ($hash) = @_; $hash->{DefFn} = "HMCCU_Define"; $hash->{UndefFn} = "HMCCU_Undef"; $hash->{SetFn} = "HMCCU_Set"; $hash->{GetFn} = "HMCCU_Get"; $hash->{ReadFn} = "HMCCU_Read"; $hash->{AttrFn} = "HMCCU_Attr"; $hash->{NotifyFn} = "HMCCU_Notify"; $hash->{ShutdownFn} = "HMCCU_Shutdown"; $hash->{parseParams} = 1; $hash->{AttrList} = "stripchar stripnumber ccuaggregate:textField-long". " ccudefaults rpcinterfaces:multiple-strict,".join(',',sort keys %HMCCU_RPC_PORT). " ccudef-hmstatevals:textField-long ccudef-substitute:textField-long". " ccudef-readingname:textField-long ccudef-readingfilter:textField-long". " ccudef-readingformat:name,namelc,address,addresslc,datapoint,datapointlc". " ccuflags:multiple-strict,extrpc,intrpc,procrpc,dptnocheck,noagg,nohmstate,logEvents,noReadings,nonBlocking". " ccuReqTimeout rpcdevice rpcinterval:2,3,5,7,10 rpcqueue". " rpcport:multiple-strict,".join(',',sort keys %HMCCU_RPC_NUMPORT). " rpcserver:on,off rpcserveraddr rpcserverport rpctimeout rpcevtimeout parfile substitute". " ccuget:Value,State ". $readingFnAttributes; } ###################################################################### # Define device ###################################################################### sub HMCCU_Define ($$) { my ($hash, $a, $h) = @_; my $name = $hash->{NAME}; return "Specify CCU hostname or IP address as a parameter" if(scalar (@$a) < 3); $hash->{host} = $$a[2]; $hash->{Clients} = ':HMCCUDEV:HMCCUCHN:HMCCURPC:HMCCURPCPROC:'; # Check if TCL-Rega process is running on CCU my $timeout = exists ($h->{waitforccu}) ? $h->{waitforccu} : 0; if (HMCCU_TCPPing ($hash->{host}, 8181, $timeout)) { $hash->{ccustate} = 'active'; } else { $hash->{ccustate} = 'unreachable'; Log3 $name, 1, "HMCCU: CCU2 port 8181 is not reachable"; } # Get CCU IP address $hash->{ccuip} = HMCCU_ResolveName ($hash->{host}, 'N/A'); # Get CCU number (if more than one) if (scalar (@$a) >= 4) { return "CCU number must be in range 1-9" if ($$a[3] < 1 || $$a[3] > 9); $hash->{CCUNum} = $$a[3]; } else { # Count CCU devices my $ccucount = 0; foreach my $d (keys %defs) { my $ch = $defs{$d}; next if (!exists ($ch->{TYPE})); $ccucount++ if ($ch->{TYPE} eq 'HMCCU' && $ch != $hash); } $hash->{CCUNum} = $ccucount+1; } $hash->{version} = $HMCCU_VERSION; $hash->{ccutype} = 'CCU2'; $hash->{RPCState} = "inactive"; $hash->{NOTIFYDEV} = "global,TYPE=(HMCCU|HMCCUDEV|HMCCUCHN)"; Log3 $name, 1, "HMCCU: Device $name. Initialized version $HMCCU_VERSION"; my ($devcnt, $chncnt, $ifcount) = HMCCU_GetDeviceList ($hash); if ($devcnt > 0) { Log3 $name, 1, "HMCCU: Read $devcnt devices with $chncnt channels from CCU ".$hash->{host}; } else { Log3 $name, 1, "HMCCU: No devices read from CCU ".$hash->{host}; } if ($ifcount > 0) { Log3 $name, 1, "HMCCU: Read $ifcount interfaces from CCU ".$hash->{host}; } else { Log3 $name, 1, "HMCCU: No RPC interfaces found on CCU ".$hash->{host}; } $hash->{hmccu}{evtime} = 0; $hash->{hmccu}{evtimeout} = 0; $hash->{hmccu}{updatetime} = 0; $hash->{hmccu}{rpccount} = 0; $hash->{hmccu}{rpcports} = $HMCCU_RPC_PORT_DEFAULT; readingsBeginUpdate ($hash); readingsBulkUpdate ($hash, "state", "Initialized"); readingsBulkUpdate ($hash, "rpcstate", "inactive"); readingsEndUpdate ($hash, 1); $attr{$name}{stateFormat} = "rpcstate/state"; return undef; } ###################################################################### # Set or delete attribute ###################################################################### sub HMCCU_Attr ($@) { my ($cmd, $name, $attrname, $attrval) = @_; my $hash = $defs{$name}; my $rc = 0; if ($cmd eq 'set') { if ($attrname eq 'ccudefaults') { $rc = HMCCU_ImportDefaults ($attrval); return HMCCU_SetError ($hash, -16) if ($rc == 0); if ($rc < 0) { $rc = -$rc; return HMCCU_SetError ($hash, "Syntax error in default attribute file $attrval line $rc"); } } elsif ($attrname eq 'ccuaggregate') { $rc = HMCCU_AggregationRules ($hash, $attrval); return HMCCU_SetError ($hash, "Syntax error in attribute ccuaggregate") if ($rc == 0); } elsif ($attrname eq 'ccuackstate') { return "HMCCU: Attribute ccuackstate is depricated. Use ccuflags with 'ackState' instead"; } elsif ($attrname eq 'ccureadings') { return "HMCCU: Attribute ccureadings is depricated. Use ccuflags with 'noReadings' instead"; } elsif ($attrname eq 'ccuflags') { my $ccuflags = AttrVal ($name, 'ccuflags', 'null'); my @flags = ($attrval =~ /(intrpc|extrpc|procrpc)/g); return "Flags extrpc, procrpc and intrpc cannot be combined" if (scalar (@flags) > 1); if ($attrval =~ /(extrpc|intrpc|procrpc)/) { my $rpcmode = $1; if ($ccuflags !~ /$rpcmode/) { return "Stop RPC server before switching RPC server" if (HMCCU_IsRPCServerRunning ($hash, undef, undef)); } } if ($attrval =~ /(extrpc|intrpc)/) { HMCCU_Log ($hash, 1, "RPC server mode $1 is deprecated. Please use procrpc instead", 0); } } elsif ($attrname eq 'rpcdevice') { return "HMCCU: Can't find HMCCURPC device $attrval" if (!exists ($defs{$attrval}) || $defs{$attrval}->{TYPE} ne 'HMCCURPC'); if (exists ($defs{$attrval}->{IODev})) { return "HMCCU: Device $attrval is not assigned to $name" if ($defs{$attrval}->{IODev} != $hash); } else { $defs{$attrval}->{IODev} = $hash; } $hash->{RPCDEV} = $attrval; } elsif ($attrname eq 'rpcinterfaces') { my @ports = split (',', $attrval); my @plist = (); foreach my $p (@ports) { my $pn = HMCCU_GetRPCServerInfo ($hash, $p, 'port'); return "Illegal RPC interface $p" if (!defined ($pn)); push (@plist, $pn); } return "No RPC interface specified" if (scalar (@plist) == 0); $hash->{hmccu}{rpcports} = join (',', @plist); $attr{$name}{"rpcport"} = $hash->{hmccu}{rpcports}; } elsif ($attrname eq 'rpcport') { my @ports = split (',', $attrval); my @ilist = (); foreach my $p (@ports) { my $pn = HMCCU_GetRPCServerInfo ($hash, $p, 'name'); return "HMCCU: Illegal RPC port $p" if (!defined ($pn)); push (@ilist, $pn); } return "No RPC port specified" if (scalar (@ilist) == 0); $hash->{hmccu}{rpcports} = $attrval; $attr{$name}{"rpcinterfaces"} = join (',', @ilist); } } elsif ($cmd eq 'del') { if ($attrname eq 'ccuaggregate') { HMCCU_AggregationRules ($hash, ''); } elsif ($attrname eq 'rpcdevice') { delete $hash->{RPCDEV} if (exists ($hash->{RPCDEV})); } elsif ($attrname eq 'rpcport' || $attrname eq 'rpcinterfaces') { $hash->{hmccu}{rpcports} = $HMCCU_RPC_PORT_DEFAULT; } } return undef; } ###################################################################### # Parse aggregation rules for readings. # Syntax of aggregation rule is: # FilterSpec[;...] # FilterSpec := {Name|Filt|Read|Cond|Else|Pref|Coll|Html}[,...] # Name := name:Name # Filt := filter:{name|type|group|room|alias}=Regexp[!Regexp] # Read := read:Regexp # Cond := if:{any|all|min|max|sum|avg|gt|lt|ge|le}=Value # Else := else:Value # Pref := prefix:{RULE|Prefix} # Coll := coll:{NAME|Attribute} # Html := html:Template ###################################################################### sub HMCCU_AggregationRules ($$) { my ($hash, $rulestr) = @_; my $name = $hash->{NAME}; # Delete existing aggregation rules if (exists ($hash->{hmccu}{agg})) { delete $hash->{hmccu}{agg}; } return if ($rulestr eq ''); my @pars = ('name', 'filter', 'if', 'else'); # Extract aggregation rules my $cnt = 0; my @rules = split (/[;\n]+/, $rulestr); foreach my $r (@rules) { $cnt++; # Set default rule parameters. Can be modified later my %opt = ( 'read' => 'state', 'prefix' => 'RULE', 'coll' => 'NAME' ); # Parse aggregation rule my @specs = split (',', $r); foreach my $spec (@specs) { if ($spec =~ /^(name|filter|read|if|else|prefix|coll|html):(.+)$/) { $opt{$1} = $2; } } # Check if mandatory parameters are specified foreach my $p (@pars) { return HMCCU_Log ($hash, 1, "Parameter $p is missing in aggregation rule $cnt.", 0) if (!exists ($opt{$p})); } my $fname = $opt{name}; my ($fincl, $fexcl) = split ('!', $opt{filter}); my ($ftype, $fexpr) = split ('=', $fincl); return 0 if (!defined ($fexpr)); my ($fcond, $fval) = split ('=', $opt{if}); return 0 if (!defined ($fval)); my ($fcoll, $fdflt) = split ('!', $opt{coll}); $fdflt = 'no match' if (!defined ($fdflt)); my $fhtml = exists ($opt{'html'}) ? $opt{'html'} : ''; # Read HTML template (optional) if ($fhtml ne '') { my %tdef; my @html; # Read template file if (open (TEMPLATE, "<$fhtml")) { @html =