#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# fluke3000.py: Driver for the Fluke 3000 FC Industrial System
#
# © 2019 Francois Drielsma (francois.drielsma@gmail.com).
#
# This file is a part of the InstrumentKit project.
# Licensed under the AGPL version 3.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Driver for the Fluke 3000 FC Industrial System
Originally contributed and copyright held by Francois Drielsma
(francois.drielsma@gmail.com)
An unrestricted license has been provided to the maintainers of the Instrument
Kit project.
"""
# IMPORTS #####################################################################
from __future__ import absolute_import
from __future__ import division
import time
from builtins import range
from enum import Enum
import quantities as pq
from instruments.abstract_instruments import Multimeter
# CLASSES #####################################################################
[docs]class Fluke3000(Multimeter):
"""The `Fluke3000` is an ecosystem of devices produced by Fluke that may be
connected simultaneously to a Fluke PC3000 wireless adapter which exposes
a serial port to the computer to send and receive commands.
The `Fluke3000` ecosystem supports the following instruments:
- Fluke 3000 FC Series Wireless Multimeter
- Fluke v3000 FC Wireless AC Voltage Module
- Fluke v3001 FC Wireless DC Voltage Module
- Fluke t3000 FC Wireless Temperature Module
`Fluke3000` is a USB instrument that communicates through a serial port
via the PC3000 dongle. The commands used to communicate to the dongle
do not follow the SCPI standard.
When the device is reset, it searches for available wireless modules
and binds them to the PC3000 dongle. At each initialization, this class
checks what device has been bound and saves their module number.
This class has been tested with the 3000 FC Wireless Multimeter and
the t3000 FC Wireless Temperature Module. They have been operated
separately and simultaneously. It does not support the Wireless AC/DC
Voltage Modules as the author did not have them on hand.
It is important to note that the mode of the multimeter cannot be set
remotely. If must be set on the device prior to the measurement. If
the measurement read back from the multimeter is not expressed in the
expected units, this module will raise an error.
Example usage:
>>> import instruments as ik
>>> mult = ik.fluke.Fluke3000.open_serial("/dev/ttyUSB0", 115200)
>>> mult.measure(mult.Mode.voltage_dc) # Measures the DC voltage
array(12.345) * V
It is crucial not to kill this program in the process of making a measurement,
as for the Fluke 3000 FC Wireless Multimeter, one has to open continuous
readout, make a read and close it. If the process is killed, the read out
may not be closed and the serial cache will be constantly filled with measurements
that will interfere with any status query. If the multimeter is stuck in
continuous trigger after a bad kill, simply do:
>>> mult.reset()
>>> mult.flush()
>>> mult.connect()
Follow the same procedure if you want to add/remove an instrument to/from
the readout chain as the code will not look for new instruments if some
have already been connected to the PC3000 dongle.
"""
def __init__(self, filelike):
"""
Initialize the instrument, and set the properties needed for communication.
"""
super(Fluke3000, self).__init__(filelike)
self.timeout = 3 * pq.second
self.terminator = "\r"
self.positions = {}
self.connect()
# ENUMS ##
[docs] class Module(Enum):
"""
Enum containing the supported modules serial numbers.
"""
#: Multimeter
m3000 = 46333030304643
#: Temperature module
t3000 = 54333030304643
[docs] class Mode(Enum):
"""
Enum containing the supported mode codes.
"""
#: AC Voltage
voltage_ac = "01"
#: DC Voltage
voltage_dc = "02"
#: AC Current
current_ac = "03"
#: DC Current
current_dc = "04"
#: Frequency
frequency = "05"
#: Temperature
temperature = "07"
#: Resistance
resistance = "0B"
#: Capacitance
capacitance = "0F"
# PROPERTIES ##
@property
def mode(self):
"""
Gets/sets the measurement mode for the multimeter.
The measurement mode of the multimeter must be set on the
device manually and cannot be set remotely. If a multimeter
is bound to the PC3000, returns its measurement mode by
making a measurement and checking the units bytes in response.
:rtype: `Fluke3000.Mode`
"""
if self.Module.m3000 not in self.positions.keys():
raise KeyError("No `Fluke3000` FC multimeter is bound")
port_id = self.positions[self.Module.m3000]
value = self.query_lines("rfemd 0{} 1".format(port_id), 2)[-1]
self.query("rfemd 0{} 2".format(port_id))
data = value.split("PH=")[-1]
return self.Mode(self._parse_mode(data))
@property
def trigger_mode(self):
"""
Gets/sets the trigger mode for the multimeter.
The only supported mode is to trigger the device once when a
measurement is queried. This device does support continuous
triggering but it would quickly flood the serial input cache as
readouts do not overwrite each other and are accumulated.
:rtype: `str`
"""
raise AttributeError("The `Fluke3000` only supports single trigger when queried")
@property
def relative(self):
"""
Gets/sets the status of relative measuring mode for the multimeter.
The `Fluke3000` FC does not support relative measurements.
:rtype: `bool`
"""
raise AttributeError("The `Fluke3000` FC does not support relative measurements")
@property
def input_range(self):
"""
Gets/sets the current input range setting of the multimeter.
The `Fluke3000` FC is an autoranging only multimeter.
:rtype: `str`
"""
return AttributeError('The `Fluke3000` FC is an autoranging only multimeter')
# METHODS #
[docs] def connect(self):
"""
Connect to available modules and returns a dictionary
of the modules found and their port ID.
"""
self.scan() # Look for connected devices
if not self.positions:
self.reset() # Reset the PC3000 dongle
timeout = self.timeout # Store default timeout
self.timeout = 30 * pq.second # PC 3000 can take a while to bind with wireless devices
self.query_lines("rfdis", 3) # Discover available modules and bind them
self.timeout = timeout # Restore default timeout
self.scan() # Look for connected devices
if not self.positions:
raise ValueError("No `Fluke3000` modules available")
[docs] def scan(self):
"""
Search for available modules and reformatturns a dictionary
of the modules found and their port ID.
"""
# Loop over possible channels, store device locations
positions = {}
for port_id in range(1, 7):
# Check if a device is connected to port port_id
output = self.query("rfebd 0{} 0".format(port_id))
if "RFEBD" not in output:
continue
# If it is, identify the device
self.read()
output = self.query_lines("rfgus 0{}".format(port_id), 2)[-1]
module_id = int(output.split("PH=")[-1])
if module_id == self.Module.m3000.value:
positions[self.Module.m3000] = port_id
elif module_id == self.Module.t3000.value:
positions[self.Module.t3000] = port_id
else:
raise NotImplementedError("Module ID {} not implemented".format(module_id))
self.positions = positions
[docs] def reset(self):
"""
Resets the device and unbinds all modules.
"""
self.query_lines("ri", 3) # Resets the device
self.query_lines("rfsm 1", 2) # Turns comms on
[docs] def read_lines(self, nlines=1):
"""
Function that keeps reading until reaches a termination
character a set amount of times. This is implemented
to handle the mutiline output of the PC3000.
:param nlines: Number of termination characters to reach
:type nlines: 'int'
:return: Array of lines read out
:rtype: Array of `str`
"""
return [self.read() for _ in range(nlines)]
[docs] def query_lines(self, cmd, nlines=1):
"""
Function used to send a query to the instrument while allowing
for the multiline output of the PC3000.
:param cmd: Command that will be sent to the instrument
:param nlines: Number of termination characters to reach
:type cmd: 'str'
:type nlines: 'int'
:return: The multiline result from the query
:rtype: Array of `str`
"""
self.sendcmd(cmd)
return self.read_lines(nlines)
[docs] def flush(self):
"""
Flushes the serial input cache.
This device outputs a terminator after each output line.
The serial input cache is flushed by repeatedly reading
until a terminator is not found.
"""
timeout = self.timeout
self.timeout = 0.1 * pq.second
init_time = time.time()
while time.time() - init_time < 1.:
try:
self.read()
except OSError:
break
self.timeout = timeout
[docs] def measure(self, mode):
"""Instruct the Fluke3000 to perform a one time measurement.
:param mode: Desired measurement mode.
:type mode: `Fluke3000.Mode`
:return: A measurement from the multimeter.
:rtype: `~quantities.quantity.Quantity`
"""
# Check that the mode is supported
if not isinstance(mode, self.Mode):
raise ValueError("Mode {} is not supported".format(mode))
# Check that the module associated with this mode is available
module = self._get_module(mode)
if module not in self.positions.keys():
raise ValueError("Device necessary to measure {} is not available".format(mode))
# Query the module
value = ''
port_id = self.positions[module]
init_time = time.time()
while time.time() - init_time < 3.:
# Read out
if mode == self.Mode.temperature:
# The temperature module supports single readout
value = self.query_lines("rfemd 0{} 0".format(port_id), 2)[-1]
else:
# The multimeter does not support single readout,
# have to open continuous readout, read, then close it
value = self.query_lines("rfemd 0{} 1".format(port_id), 2)[-1]
self.query("rfemd 0{} 2".format(port_id))
# Check that value is consistent with the request, break
if "PH" in value:
data = value.split("PH=")[-1]
if self._parse_mode(data) != mode.value:
if self.Module.m3000 in self.positions.keys():
self.query("rfemd 0{} 2".format(self.positions[self.Module.m3000]))
self.flush()
else:
break
# Parse the output
value = self._parse(value, mode)
# Return with the appropriate units
units = UNITS[mode]
return value * units
def _get_module(self, mode):
"""Gets the module associated with this measurement mode.
:param mode: Desired measurement mode.
:type mode: `Fluke3000.Mode`
:return: A Fluke3000 module.
:rtype: `Fluke3000.Module`
"""
if mode == self.Mode.temperature:
return self.Module.t3000
return self.Module.m3000
def _parse(self, result, mode):
"""Parses the module output.
:param result: Output of the query.
:param mode: Desired measurement mode.
:type result: `string`
:type mode: `Fluke3000.Mode`
:return: A measurement from the multimeter.
:rtype: `Quantity`
"""
# Check that a value is contained
if "PH" not in result:
raise ValueError("Cannot parse a string that does not contain a return value")
# Isolate the data string from the output
data = result.split('PH=')[-1]
# Check that the multimeter is in the right mode (fifth byte)
if self._parse_mode(data) != mode.value:
error = ("Mode {} was requested but the Fluke 3000FC Multimeter "
"is in mode {} instead. Could not read the requested "
"quantity.").format(mode.name, self.Mode(data[8:10]).name)
raise ValueError(error)
# Extract the value from the first two bytes
value = self._parse_value(data)
# Extract the prefactor from the fourth byte
scale = self._parse_factor(data)
# Combine and return
return scale*value
@staticmethod
def _parse_mode(data):
"""Parses the measurement mode.
:param data: Measurement output.
:type data: `str`
:return: A Mode string.
:rtype: `str`
"""
# The fixth dual hex byte encodes the measurement mode
return data[8:10]
@staticmethod
def _parse_value(data):
"""Parses the measurement value.
:param data: Measurement output.
:type data: `str`
:return: A value.
:rtype: `float`
"""
# The second dual hex byte is the most significant byte
return int(data[2:4]+data[:2], 16)
@staticmethod
def _parse_factor(data):
"""Parses the measurement prefactor.
:param data: Measurement output.
:type data: `str`
:return: A prefactor.
:rtype: `float`
"""
# Convert the fourth dual hex byte to an 8 bits string
byte = format(int(data[6:8], 16), '08b')
# The first bit encodes the sign (0 positive, 1 negative)
sign = 1 if byte[0] == '0' else -1
# The second to fourth bits encode the metric prefix
code = int(byte[1:4], 2)
if code not in PREFIXES.keys():
raise ValueError("Metric prefix not recognized: {}".format(code))
prefix = PREFIXES[code]
# The sixth and seventh bit encode the decimal place
scale = 10**(-int(byte[5:7], 2))
# Return the combination
return sign*prefix*scale
# UNITS #######################################################################
UNITS = {
None: 1,
Fluke3000.Mode.voltage_ac: pq.volt,
Fluke3000.Mode.voltage_dc: pq.volt,
Fluke3000.Mode.current_ac: pq.amp,
Fluke3000.Mode.current_dc: pq.amp,
Fluke3000.Mode.frequency: pq.hertz,
Fluke3000.Mode.temperature: pq.celsius,
Fluke3000.Mode.resistance: pq.ohm,
Fluke3000.Mode.capacitance: pq.farad
}
# METRIC PREFIXES #############################################################
PREFIXES = {
0: 1e0, # None
2: 1e6, # Mega
3: 1e3, # Kilo
4: 1e-3, # milli
5: 1e-6, # micro
6: 1e-9 # nano
}