#!/usr/bin/python
from vesna.alh import common, ALHProxy, ALHException
import datetime
import glob
import logging
from optparse import OptionParser
import os
import re
import sys
import time

log = logging.getLogger(__name__)

class NodeResource: 

	MIN_VERSION = None

	APPLICATION = None

	def __init__(self, node):
		self.node = node

		self.value = None
		self.value_cached = False

	def get(self):
		if not self.node.is_at_least(self.MIN_VERSION):
			return None

		if (self.APPLICATION is not None) and (self.node.application not in self.APPLICATION):
			return None

		if not self.value_cached:
			try:
				self.value = self.fetch()
			except Exception:
				log.exception("fetching resource value failed")
				self.value = None
			else:
				self.value_cached = True

		return self.value

	def munin_write(self, outf):
		outf.write("%s.value %f\n" % (self.LABEL, self.get()))

class NodeFritzBoxUptime(NodeResource):
	APPLICATION = ("Coordinator+FritzBox",)

	MUNIN_CONFIG = """graph_title FritzBox uptime
graph_args --base 1000 -l 0
graph_scale no
graph_vlabel uptime in hours
graph_category SNE
fbuptime.label uptime
fbuptime.min 0
"""

	LABEL = "fbuptime"

	def fetch(self):
		uptime = float(self.node.alh.get("fritzbox/uptime").strip())
		return uptime / 3600.0

class NodeUptime(NodeResource):
	MUNIN_CONFIG = """graph_title Uptime
graph_args --base 1000 -l 0
graph_scale no
graph_vlabel uptime in days
graph_category SNC
uptime.label uptime
uptime.min 0
"""

	LABEL = "uptime"

	def fetch(self):
		uptime = float(self.node.alh.get("uptime").strip())

		# old version don't count uptime from 0
		old_uptime_offset = 1325376000.0
		if uptime >= old_uptime_offset:
			uptime -= old_uptime_offset

		# if node has such a low uptime, it might have been
		# power cycles.
		#
		# make a prog/firstCall so that it doesn't reset
		# itself continuously.
		if uptime < 3600.0:
			self.node.alh.post("prog/firstCall", "1")

		return uptime / 86400.0

class NodeMCUTemperature(NodeResource):
	MUNIN_CONFIG = """graph_title MCU temperature
graph_args --base 1000
graph_scale no
graph_vlabel degrees Celsius
graph_category SNC
mcutemp.label mcutemp
"""

	LABEL = "mcutemp"

	def fetch(self):
		s = self.node.alh.get("sensor/mcuTemp").strip()
		g = re.search("MCU temperature is ([0-9.]+) C", s)
		return float(g.group(1))

class NodeVCCVoltage(NodeResource):
	MIN_VERSION = "2.41"

	MUNIN_CONFIG = """graph_title VCC voltage
graph_args --base 1000
graph_scale no
graph_vlabel volts
graph_category SNC
vcc.label vcc
vcc.min 0
"""

	LABEL = "vcc"

	def fetch(self):
		resp = self.node.alh.get("voltage/vcc").strip()
		g = re.match("^([0-9]+) mV\s*$", resp)
		return float(g.group(1)) * 1e-3

class NodeZigBitFlashCRC(NodeResource):
	MIN_VERSION = "2.43"

	MUNIN_CONFIG = """graph_title ZigBit Flash 0x11100 - 0x11200 CRC32
graph_vlabel CRC32
graph_category SNR
zbcrc.label CRC32
"""

	LABEL = "zbcrc"

	def fetch(self):
		start = 0x11100
		end   = 0x11200

		resp = self.node.alh.get("debug/zigbitflashcrc",
				"start=%d&end=%d" % (start, end))
		crc32 = int(resp, 16)

		return crc32

class NodeFirmwareVersion(NodeResource):
	LABEL = "version"

	def fetch(self):
		return self.node.version

class NodePingTime(NodeResource):
	MUNIN_CONFIG = """graph_title Ping time
graph_args --base 1000
graph_vlabel seconds
graph_category SNR
ping.label ping
"""

	LABEL = "ping"

	def fetch(self):
		return self.node.ping_time

class NodeTDAStatus(NodeResource):
	APPLICATION = ("NodeSpectrumSensor",)

	LABEL = "tda"

	MUNIN_CONFIG = """multigraph tdatemp
graph_title Tuner temperature
graph_args --base 1000
graph_scale no
graph_vlabel degrees Celsius
graph_category SNE
tdatemp.label tdatemp

multigraph tdaerrors
graph_title Tuner bus errors
graph_args --base 1000
graph_vlabel bus errors per second
graph_category SNE
tdaerrors.label tdaerrors
tdaerrors.type DERIVE
tdaerrors.min 0

multigraph tdatimeouts
graph_title Tuner timeouts
graph_args --base 1000
graph_vlabel timeouts per second
graph_category SNE
tdatimeouts.label tdatimeouts
tdatimeouts.type DERIVE
tdatimeouts.min 0
"""

	def fetch(self):
		resp = self.node.alh.get("sensing/deviceStatus").strip()

		g = re.search("Temperature +: +([-0-9]+) C", resp)
		temp = g.group(1) if g else 'U'

		g = re.search("bus errors +: +([0-9]+)", resp)
		errors = g.group(1) if g else 'U'

		g = re.search("timeouts +: +([0-9]+)", resp)
		timeouts = g.group(1) if g else 'U'

		return (temp, errors, timeouts)

	def munin_write(self, outf):
		outf.write("""multigraph tdatemp
tdatemp.value %s
multigraph tdaerrors
tdaerrors.value %s
multigraph tdatimeouts
tdatimeouts.value %s
""" % (self.get()[0], self.get()[1], self.get()[2]))

class NodeRadioStatistics(NodeResource):
	MIN_VERSION = "2.4"

	MUNIN_CONFIG = """multigraph bytes
graph_order rx tx
graph_title ZigBit traffic
graph_args --base 1000
graph_vlabel bits in (-) / out (+) per second
graph_category SNR
rx.label received
rx.type DERIVE
rx.min 0
rx.graph no
rx.cdef rx,8,*
tx.label bps
tx.type DERIVE
tx.min 0
tx.negative rx
tx.cdef tx,8,*

multigraph overflow
graph_title ZigBit buffer overflows
graph_args --base 1000
graph_vlabel lost packets per second
graph_category SNR
overflow.label overflow
overflow.type DERIVE
overflow.min 0

multigraph timeouts
graph_title ZigBit module response timeouts
graph_args --base 1000
graph_vlabel timeouts per second
graph_category SNR
timeouts.label timeouts
timeouts.type DERIVE
timeouts.min 0
"""

	LABEL = "radio"

	def fetch(self):
		stats = self.node.alh.get("radio/statistics").strip()
		# [ tx, rx, overflows, timeouts ]
		stats = map(int, filter(lambda x:x.isdigit(), stats.split()))
		if len(stats) >= 4:
			return stats

	def munin_write(self, outf):
		outf.write("""multigraph bytes
tx.value %d
rx.value %d
multigraph overflow
overflow.value %d
multigraph timeouts
timeouts.value %d
""" % (self.get()[1], self.get()[0], self.get()[2], self.get()[3]))

class MonitoredNode:
	RESOURCES = [
		NodeFirmwareVersion,
		NodePingTime,
		NodeUptime,
		NodeMCUTemperature,
		NodeVCCVoltage,
		NodeRadioStatistics,
		NodeTDAStatus,
		NodeFritzBoxUptime,
		NodeZigBitFlashCRC ]

	def __init__(self, alh):
		self.alh = alh

		self._ping()

		self.neighbor_addrs = []

		self.resources = dict(	(resource.LABEL, resource(self))
					for resource in self.RESOURCES )

	def _ping(self):
		try:
			start_time = time.time()
			resp = self.alh.get("hello")
			self.ping_time = time.time() - start_time
		except ALHException:
			log.exception("pinging node failed")
			self.application = None
			self.version = None
			return

		g = re.match("(.+) version ([0-9.]+)", resp)
		if g is None:
			self.application = None
			self.version = None
		else:
			self.application = g.group(1)
			self.version = g.group(2)

	def _get_neighbor_addrs(self):

		# Older versions have a buggy implementation that has a
		# possiblity of bricking a node if this request is made.
		if not self.is_at_least("2.16"):
			return

		resp = self.alh.get("radio/neighbors")

		for line in resp.split("\r\n"):
			fields = line.split(" | ")
			if len(fields) == 6:
				try:
					self.neighbor_addrs.append(int(fields[3]))
				except ValueError:
					pass

	def get_neighbor_addrs(self):
		if not self.neighbor_addrs:
			self._get_neighbor_addrs()

		return self.neighbor_addrs

	def is_at_least(self, min_version):
		"""Return True if this node has at least version min_version or newer.

		Examples:

		"1.0" is newer than None (version can't be retrieved from node)

		"2.0" is newer than "1.0"

		"2.1" is newer than "2.0"

		"2.1.1" is newer than "2.1"

		"2.1.2" is newer than "2.1.1"
		"""

		if self.version is None:
			# return False for bad nodes, even if min_version is None
			return False

		def str_to_f(version):
			if version is None:
				return None
			else:
				return map(int, version.split("."))

		return str_to_f(self.version) >= str_to_f(min_version)

def traverse_network(options):
	coordinator = common.get_coordinator(options)
	coordinator.RETRIES = 1

	queue = [0]
	network = {}

	while queue:
		addr = queue.pop()
		if addr not in network:
			try:
				if addr == 0:
					alh = coordinator
				else:
					alh = ALHProxy(coordinator, addr)

				node = MonitoredNode(alh)
				network[addr] = node

				for next_addr in node.get_neighbor_addrs():
					queue.insert(0, next_addr)
			except Exception:
				log.exception("fetching neighbors for node %d failed" % (addr,))
	
	return network

def do_munin(options, visited):
	refreshed = set()

	for resource in MonitoredNode.RESOURCES:
		if hasattr(resource, "MUNIN_CONFIG"):
			label = resource.LABEL

			path = "%s/config_%s" % (options.output, label)
			refreshed.add(path)

			outf = open(path, "w")
			outf.write(resource.MUNIN_CONFIG)
			outf.close()

			for current_id, nodeinfo in visited.iteritems():
				resource = nodeinfo.resources[label]
				if resource.get() is not None:
					path = "%s/node_%d_%s" % (options.output, current_id, label)
					refreshed.add(path)

					outf = open(path, "w")
					nodeinfo.resources[label].munin_write(outf)
					outf.close()

	for path in glob.glob("%s/*" % (options.output,)):
		if path not in refreshed:
			os.unlink(path)

def do_stats(options, visited):
	visited = sorted((id, info) for id, info in visited.iteritems())

	print "ID\tApplication         \tVersion\tUptime"
	for id, nodeinfo in visited:
		uptime_days = nodeinfo.resources['uptime'].get()
		if uptime_days is not None:
			uptime = datetime.timedelta(days=uptime_days)
		else:
			uptime = None

		row = [	
			id,
			str(nodeinfo.application).ljust(20),
			nodeinfo.version,
			uptime,
		]

		print '\t'.join(map(str, row))

def main():
	parser = OptionParser(usage="%prog [options]")

	common.add_communication_options(parser)

	parser.add_option("-o", "--output", dest="output", metavar="PATH",
			help="PATH to write dotfile or Munin data to")

	parser.add_option("-t", "--dot", dest="do_dot", action="store_true",
			help="Output a DOT file")
	parser.add_option("-m", "--munin", dest="do_munin", action="store_true",
			help="Output a directory for the Munin plug-in")
	parser.add_option("--log-path", dest="log_path", metavar="PATH",
			help="PATH to write log")

	(options, args) = parser.parse_args()

	if options.log_path:
		logging.basicConfig(filename=options.log_path, level=logging.INFO)

	log.info("alh-map started at %s" % (datetime.datetime.now(),))

	visited = traverse_network(options)

	# Ugly hack until we upgrade the coordinator
	if (options.cluster_id == 10003) and (visited[0].application == "Hello Application"):
		log.warning("Correcting application name. Please upgrade coordinator firmware!")
		visited[0].application = "Coordinator+FritzBox"

	if options.do_dot:

		if options.output:
			outf = open(options.output, "w")
		else:
			outf = sys.stdout

		outf.write("digraph net {\n")
		for current_id, nodeinfo in visited.iteritems():
			if nodeinfo.neighbors:
				for next_id in nodeinfo.neighbors:
					outf.write("n%d -> n%d\n" % (current_id, next_id))
		outf.write("}\n")
	elif options.do_munin:
		if not options.output:
			log.error("Please specify Munin data directory with --output")
		else:
			do_munin(options, visited)
	else:
		do_stats(options, visited)

	log.info("alh-map ended at %s" % (datetime.datetime.now(),))

main()
