lib/ekm-omnimeter/meter.rb in ekm-omnimeter-0.2.3 vs lib/ekm-omnimeter/meter.rb in ekm-omnimeter-0.2.4

- old
+ new

@@ -11,18 +11,21 @@ class Meter VALID_POWER_CONFIGURATIONS = [:single_phase_2wire, :single_phase_3wire, :three_phase_3wire, :three_phase_4wire] # Initialization attributes - attr_reader :meter_number, :remote_address, :remote_port, :power_configuration, :last_read_timestamp + attr_reader :meter_number, :remote_address, :remote_port, :verify_checksums, :power_configuration, :last_read_timestamp # Request A #attr_reader :meter_type, :meter_firmware, :address, :total_active_kwh, :total_kvarh, :total_rev_kwh, :three_phase_kwh, :three_phase_rev_kwh, :resettable_kwh, :resettable_reverse_kwh, :volts_l1, :volts_l2, :volts_l3, :amps_l1, :amps_l2, :amps_l3, :watts_l1, :watts_l2, :watts_l3, :watts_total, :cosϴ_l1, :cosϴ_l2, :cosϴ_l3, :var_l1, :var_l2, :var_l3, :var_total, :freq, :pulse_count_1, :pulse_count_2, :pulse_count_3, :pulse_input_hilo, :direction_of_current, :outputs_onoff, :kwh_data_decimal_places, # Request B #attr_reader :t1_t2_t3_t4_kwh, :t1_t2_t3_t4_rev_kwh, :maximum_demand, :maximum_demand_time, :pulse_ratio_1, :pulse_ratio_2, :pulse_ratio_3, :ct_ratio, :auto_reset_md, :settable_imp_per_kWh_constant + # iSerial v4 Spec From http://documents.ekmmetering.com/Omnimeter-Pulse-v.4-Protocol.pdf + # %w(01 52 31 02 30 30 31 31 28 29 03 13 16).map{|a| a.to_i(16).chr}.join + # Mix in the ability to log include Logging def initialize(options) @@ -30,20 +33,23 @@ # Test logging call @logger.info "Initializing Meter" # Prepend the meter number with the correct amount of leading zeros - @meter_number = options[:meter_number].to_s.rjust(12, '0') - @remote_address = options[:remote_address] || '192.168.0.125' - @remote_port = options[:remote_port] || 50000 + @meter_number = options[:meter_number].to_s.rjust(12, '0') + @remote_address = options[:remote_address] || '192.168.0.125' + @remote_port = options[:remote_port] || 50000 + @verify_checksums = options[:verify_checksums] || false @logger.debug "meter_number: #{meter_number}" @logger.debug "remote_address: #{remote_address}" @logger.debug "remote_port: #{remote_port}" + @logger.debug "verify_checksums: #{verify_checksums}" # Collect the power configurations if VALID_POWER_CONFIGURATIONS.index(options[:power_configuration]) @power_configuration = options[:power_configuration] + @logger.debug "power_configuration: #{@power_configuration}" else raise EkmOmnimeterError, "Invalid power configuration #{options[:power_configuration]}. Valid values are #{VALID_POWER_CONFIGURATIONS.join(', ')}" end # Collect pulse inputs @@ -66,33 +72,25 @@ request_a() request_b() @values end - # Formatted datetime reported by meter during last read - def meter_timestamp - "20#{current_time[0,2]}-#{current_time[2,2]}-#{current_time[4,2]} #{current_time[6,2]}:#{current_time[ 8,2]}:#{current_time[10,2]}" - end # Attribute handler that delegates attribute reads to the values hash def method_missing(method_sym, *arguments, &block) - - #@logger.debug "method_missing #{method_sym.inspect}" - # Only refresh data if its more than 0.25 seconds old et = @last_read_timestamp.nil? ? 0 : (Time.now - @last_read_timestamp) - #logger.debug "Elapsed time since last read #{et}" + @logger.debug "Elapsed time since last read #{et}" if et > 250 - @logger.info "More than 250 milliseconds have passed, updating data" + @logger.info "More than 250 milliseconds have passed since last read. Triggering refresh." read() end if @values.include? method_sym - #logger.debug "Found #{method_sym}" @values[method_sym] else - #logger.debug "Didn't find #{method_sym}" + @logger.debug "method_missing failed to find #{method_sym} in the Meter.values cache" super end end # Attribute responder that delegates check of attribute existence to the values hash @@ -103,63 +101,10 @@ super end end - ## Request A - #d[:meter_type] # 2 Byte Meter Type - #d[:meter_firmware] # 1 Byte Meter Firmware - #d[:address] # 12 Bytes Address - #d[:total_active_kwh] # 8 Bytes total Active kWh - #d[:total_kvarh] # 8 Bytes Total kVARh - #d[:total_rev_kwh] # 8 Bytes Total Rev.kWh - #d[:three_phase_kwh] # 24 Bytes 3 phase kWh - #d[:three_phase_rev_kwh] # 24 Bytes 3 phase Rev.kWh - #d[:resettable_kwh] # 8 Bytes Resettable kWh - #d[:resettable_reverse_kwh] # 8 bytes Resettable Reverse kWh - #d[:volts_l1] # 4 Bytes Volts L1 - #d[:volts_l2] # 4 Bytes Volts L2 - #d[:volts_l3] # 4 Bytes Volts L3 - #d[:amps_l1] # 5 Bytes Amps L1 - #d[:amps_l2] # 5 Bytes Amps L2 - #d[:amps_l3] # 5 Bytes Amps L3 - #d[:watts_l1] # 7 Bytes Watts L1 - #d[:watts_l2] # 7 Bytes Watts L2 - #d[:watts_l3] # 7 Bytes Watts L3 - #d[:watts_total] # 7 Bytes Watts Total - #d[:cosϴ_l1] # 4 Bytes Cosϴ L1 - #d[:cosϴ_l2] # 4 Bytes Cosϴ L2 - #d[:cosϴ_l3] # 4 Bytes Cosϴ L3 - #d[:var_l1] # 7 Bytes VAR L1 - #d[:var_l2] # 7 Bytes VAR L2 - #d[:var_l3] # 7 Bytes VAR L3 - #d[:var_total] # 7 Bytes VAR Total - #d[:freq] # 4 Bytes Freq - #d[:pulse_count_1] # 8 Bytes Pulse Count 1 - #d[:pulse_count_2] # 8 Bytes Pulse Count 2 - #d[:pulse_count_3] # 8 Bytes Pulse Count 3 - #d[:pulse_input_hilo] # 1 Byte Pulse Input Hi/Lo - #d[:direction_of_current] # 1 Bytes direction of current - #d[:outputs_onoff] # 1 Byte Outputs On/Off - #d[:kwh_data_decimal_places] # 1 Byte kWh Data Decimal Places - - ## Request B - #d[:t1_t2_t3_t4_kwh] # 32 Bytes T1, T2, T3, T4 kwh - #d[:t1_t2_t3_t4_rev_kwh] # 32 Bytes T1, T2, T3, T4 Rev kWh - #d[:maximum_demand] # 8 Bytes Maximum Demand - #d[:maximum_demand_time] # 1 Byte Maximum Demand Time - #d[:pulse_ratio_1] # 4 Bytes Pulse Ratio 1 - #d[:pulse_ratio_2] # 4 Bytes Pulse Ratio 2 - #d[:pulse_ratio_3] # 4 Bytes Pulse Ratio 3 - #d[:ct_ratio] # 4 Bytes CT Ratio - #d[:auto_reset_md] # 1 Bytes Auto Reset MD - #d[:settable_imp_per_kWh_constant] # 4 Bytes Settable Imp/kWh Constant - - - # iSerial v4 Spec From http://documents.ekmmetering.com/Omnimeter-Pulse-v.4-Protocol.pdf - # %w(01 52 31 02 30 30 31 31 28 29 03 13 16).map{|a| a.to_i(16).chr}.join - # Returns the correct measurement for voltage, current, and power based on the corresponding power_configuration def calculate_measurement(m1, m2, m3) if power_configuration == :single_phase_2wire m1 elsif power_configuration == :single_phase_3wire @@ -183,10 +128,24 @@ else logger.error "Could not cast #{s} to #{p} decimal places" end end + # Power factor values come back as C099 which need to be cast to C0.99 + def cast_power_factor(s) + "#{s[0]}#{s[1,3].to_f / 100.0}" + end + + # Formatted datetime reported by meter during last read. + # Raw string is formatted as YYMMDDWWHHMMSS where YY is year without century, and WW is week day with Sunday as the first day of the week + #"20#{current_time[0,2]}-#{current_time[2,2]}-#{current_time[4,2]} #{current_time[8,2]}:#{current_time[ 10,2]}:#{current_time[12,2]}" + def as_datetime(s) + DateTime.new("20#{s[0,2]}".to_i, s[2,2].to_i, s[4,2].to_i, s[8,2].to_i, s[10,2].to_i, s[12,2].to_i, '-4') + end + + # All values are returned without decimals. This method loops over all + # the values and sets them to the correct precision def cast_response_to_correct_types(d) # Integers [:kwh_data_decimal_places, :watts_l1, @@ -222,14 +181,11 @@ logger.debug "Casting #{k}" d[k] = to_f_with_decimal_places(d[k], 1) if d.has_key?(k) end # Floats with precision 2 - [:power_factor_1, - :power_factor_2, - :power_factor_3, - :frequency + [:frequency ].each do |k| logger.debug "Casting #{k}" d[k] = to_f_with_decimal_places(d[k], 2) if d.has_key?(k) end @@ -327,34 +283,44 @@ d[:pulse_input_hilo] = a.shift(1) # 1 Byte Pulse Input Hi/Lo d[:direction_of_current] = a.shift(1) # 1 Bytes direction of current d[:outputs_onoff] = a.shift(1) # 1 Byte Outputs On/Off d[:kwh_data_decimal_places] = a.shift(1) # 1 Byte kWh Data Decimal Places a.shift(2) # 2 Bytes Reserved - d[:current_time] = a.shift(14) # 14 Bytes Current Time + meter_timestamp = a.shift(14).join('') # 14 Bytes Current Time a.shift(6) # 30 30 21 0D 0A 03 - d[:CRC16] = a.shift(2) # 2 Bytes CRC16 + d[:checksum] = a.shift(2) # 2 Bytes CRC16 # Smash arrays into strungs d.each {|k,v| d[k] = v.join('')} + if verify_checksums + if Crc16.check_crc16(response, d[:checksum]) + @logger.debug "Checksum matches" + else + @logger.error "CRC16 Checksum doesn't match. Expecting #{d[:checksum]} but was #{Crc16.crc16(response)}" + #raise EkmOmnimeterError, "Checksum doesn't match" + end + end + # Cast types @values[:kwh_data_decimal_places] = d[:kwh_data_decimal_places].to_i + d[:power_factor_1] = cast_power_factor(d[:power_factor_1]) + d[:power_factor_2] = cast_power_factor(d[:power_factor_2]) + d[:power_factor_3] = cast_power_factor(d[:power_factor_3]) + d[:meter_timestamp] = as_datetime(meter_timestamp) cast_response_to_correct_types(d) # Lookup mapped values - puts "d[:pulse_input_hilo] = #{d[:pulse_input_hilo].inspect}" - d[:pulse_1_input], d[:pulse_2_input], d[:pulse_3_input] = lookup_pulse_input_states(d[:pulse_input_hilo]) d[:current_direction_l1], d[:current_direction_l2], d[:current_direction_l3] = lookup_direction_of_current(d[:direction_of_current]) d[:output_1], d[:output_2] = lookup_output_states(d[:outputs_onoff]) # Merge to values and reset time @values.merge!(d) @last_read_timestamp = Time.now # Calculate totals based on wiring configuration - @values[:meter_timestamp] = meter_timestamp @values[:volts] = calculate_measurement(volts_l1, volts_l2, volts_l3) @values[:amps] = calculate_measurement(amps_l1, amps_l2, amps_l3) @values[:watts] = calculate_measurement(watts_l1, watts_l2, watts_l3) @values[:total_forward_kwh] = total_kwh - total_reverse_kwh @values[:net_kwh] = total_forward_kwh - total_reverse_kwh @@ -362,11 +328,10 @@ # Return the hash as an open struct return d end - # Request B # TODO: Instead of pre-parsing and casting everything, refactor this so that only the response string gets saved, and parse out values that are accessed. def request_b # 2F 3F 12 Bytes Address 30 31 21 0D 0A @@ -426,18 +391,31 @@ d[:ct_ratio] = a.shift(4) # 4 Bytes CT Ratio d[:auto_reset_max_demand] = a.shift(1) # 1 Bytes Auto Reset MD d[:settable_pulse_per_kwh_ratio] = a.shift(4) # 4 Bytes Settable Imp/kWh Constant # Diff from request A end a.shift(56) # 56 Bytes Reserved - d[:current_time] = a.shift(14) # 14 Bytes Current Time + meter_timestamp = a.shift(14).join('') # 14 Bytes Current Time a.shift(6) # 30 30 21 0D 0A 03 - d[:checksum] = a.shift(2) # 2 Bytes CRC16 + d[:checksum] = a.shift(2) # 2 Bytes CRC16 # Smash arrays into strungs d.each {|k,v| d[k] = v.join('')} + if verify_checksums + if Crc16.check_crc16(response, d[:checksum]) + @logger.debug "Checksum matches" + else + @logger.error "CRC16 Checksum doesn't match. Expecting #{d[:checksum]} but was #{Crc16.crc16(response)}" + #raise EkmOmnimeterError, "Checksum doesn't match" + end + end + # Cast types + d[:power_factor_1] = cast_power_factor(d[:power_factor_1]) + d[:power_factor_2] = cast_power_factor(d[:power_factor_2]) + d[:power_factor_3] = cast_power_factor(d[:power_factor_3]) + d[:meter_timestamp] = as_datetime(meter_timestamp) cast_response_to_correct_types(d) # Lookup mapped values d[:maximum_demand_period] = lookup_demand_period_time(d[:maximum_demand_period]) d[:auto_reset_max_demand] = lookup_auto_reset_max_demand_period(d[:auto_reset_max_demand]) @@ -445,22 +423,20 @@ # Merge to values and reset time @values.merge!(d) @last_read_timestamp = Time.now # Calculate totals based on wiring configuration - @values[:meter_timestamp] = meter_timestamp @values[:volts] = calculate_measurement(volts_l1, volts_l2, volts_l3) @values[:amps] = calculate_measurement(amps_l1, amps_l2, amps_l3) @values[:watts] = calculate_measurement(watts_l1, watts_l2, watts_l3) # Return the hash as an open struct return d end - # Gets remote EKM meter data using iSerial defaults # meter_number is the meters serial number. leading 0s not required. # remote_address is the IP address of the ethernet-RS485 converter # remote_port is the TCP port number the converter is listening to (50000 in my case) # We do not check the checksum - I'm lazy. Probably should. Running for 8 months, querying @@ -471,27 +447,27 @@ # connect to the meter and check to make sure we connected begin socket = TCPSocket.new(remote_address, remote_port) - logger.debug "Socket open" unless logger.nil? + @logger.debug "Socket open" # Send request to the meter - logger.debug "Request: #{request}" unless logger.nil? + @logger.debug "Request: #{request}" socket.write(request) # Receive a response of 255 bytes response = socket.read(read_bytes) - logger.debug "Socket response #{response.length}" unless logger.nil? - logger.debug response unless logger.nil? + @logger.debug "Socket response #{response.length}" + @logger.debug response rescue Exception => ex - logger.error "Exception\n#{ex.message}\n#{ex.backtrace.join("\n")}" unless logger.nil? + @logger.error "Exception\n#{ex.message}\n#{ex.backtrace.join("\n")}" ensure # EKM Meter software sends this just before closing the connection, so we will too socket.write "\x0a\x03\x32\x3d" socket.close - logger.debug "Socket closed" unless logger.nil? + @logger.debug "Socket closed" end return response end