#!/usr/bin/env python
# -*- coding: utf-8 -*-

##
#   Project: gWakeOnLan - Wake up your machines using Wake on LAN.
#    Author: Fabio Castelli <muflone@vbsimple.net>
# Copyright: 2009-2010 Fabio Castelli
#   License: GPL-2+
#  This program is 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.
# 
#  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 General Public License for
#  more details.
# 
# On Debian GNU/Linux systems, the full text of the GNU General Public License
# can be found in the file /usr/share/common-licenses/GPL-2.

import gtk
import pygtk
import ConfigParser
import os.path
import sys
import time
import struct
import socket
import gettext
import locale
from optparse import OptionParser
from gettext import gettext as _

__file_path__ = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = '~/.gwakeonlan'
APP_NAME = 'gwakeonlan'
APP_TITLE = 'gWakeOnLan'
APP_VERSION = '0.5.1'
COL_SELECTED, COL_MACHINE, COL_ADDRESS, COL_REQTYPE, COL_DESTINATION, COL_PORTNR = range(6)
SECTION_MAINWIN = 'main window'
SECTION_HOSTS = 'hosts'
ARP_CACHE_FILENAME = '/proc/net/arp'
VERBOSE_LEVEL_QUIET, VERBOSE_LEVEL_NORMAL, VERBOSE_LEVEL_MAX = range(3)

PATHS = {
  'locale': [
    '%s/po' % __file_path__,
    '%s/share/locale' % sys.prefix],
  'ui': [
    '%s/data' % __file_path__,
    '%s/share/%s' % (sys.prefix, APP_NAME)],
  'gfx': [
    '%s/data' % __file_path__,
    '%s/share/%s' % (sys.prefix, APP_NAME)],
  'doc': [
    '%s/doc' % __file_path__,
    '%s/share/doc/%s' % (sys.prefix, APP_NAME)]
}

def __searchPath(key, append = ''):
  "Returns the correct path for the specified key"
  for path in PATHS[key]:
    if os.path.isdir(path):
      if append:
        return os.path.join(path, append)
      else:
        return path

APP_LOGO = __searchPath('gfx', '%s.svg' % APP_NAME)

def logText(text, verbose_level=VERBOSE_LEVEL_NORMAL):
  "Print a text with current date and time based on verbose level"
  if verbose_level <= options.verbose_level:
    print '[%s] %s' % (time.strftime('%Y/%m/%d %H:%M:%S'), text)

def readTextFile(filename):
  "Read a text file and return its content"
  try:
    f = open(filename, 'r')
    text = f.read()
    f.close()
  except:
    text = ''
  return text

def showMachineDialog(action_add, machine, mac, destination, portnr):
  "Show the machine dialog with the indicated values, run, then hide"
  dlgMachine.set_title(action_add and _('Add machine') or _('Edit machine'))
  txtMachineName.set_text(machine)
  txtMACAddress.set_text(mac)
  txtHostAddress.set_text(destination)
  spinPortNumber.set_value(portnr)
  # Select local or internet radio button
  if destination == '255.255.255.255':
    radioRequestLocal.set_active(True)
  else:
    radioRequestInternet.set_active(True)
  txtMachineName.grab_focus()
  response = 0
  # Hide errors label
  lblError.set_property('visible', False)
  # Repeat until there're no more errors or dialog is closed
  while not response:
    response = dlgMachine.run()
    mac = txtMACAddress.get_text()
    # Replace separator characters
    for c in (':-= '):
      mac = mac.replace(c, '')
    if response == gtk.RESPONSE_OK:
      # Check values for valid response
      err_msg = ''
      if not txtMachineName.get_text():
        err_msg = _('Missing machine name')
      elif not (len(mac) == 12 and all(c in '1234567890ABCDEF' for c in mac.upper())):
        err_msg = _('Invalid MAC address')
      elif radioRequestInternet.get_active() and not txtHostAddress.get_text():
        err_msg = _('Invalid destination host')
      # There was an error, don't close the dialog
      if err_msg:
        lblError.set_property('visible', True)
        lblError.set_markup('<span foreground="red"><b>%s</b></span>' % err_msg)
        # Don't close the dialog if there's some error
        response = 0
  # Replace MAC address without separators
  txtMACAddress.set_text(mac)
  # If local request set the broadcast mask
  if radioRequestLocal.get_active():
    txtHostAddress.set_text('255.255.255.255')
  dlgMachine.hide()
  return response

def formatMAC(mac):
  "Return the mac address formatted with colon"
  return ':'.join([mac[i:i+2] for i in xrange(0, len(mac), 2)]).upper()

def get_request_type(host):
  "Return the request type for the specified host"
  return host == '255.255.255.255' and _('Local') or _('Internet')

def saveConfig():
  "Save configuration for window and machines"
  config = ConfigParser.RawConfigParser()
  # Allow saving in case sensitive (useful for machine names)
  config.optionxform = str

  # Main window settings section
  config.add_section(SECTION_MAINWIN)
  # Window position
  position = winMain.get_position()
  config.set(SECTION_MAINWIN, 'left', position[0])
  config.set(SECTION_MAINWIN, 'top', position[1])
  # Window size
  size = winMain.get_size()
  config.set(SECTION_MAINWIN, 'width', size[0])
  config.set(SECTION_MAINWIN, 'height', size[1])

  # Hosts section
  config.add_section(SECTION_HOSTS)
  for machine in modelMachines:
    config.set(SECTION_HOSTS, machine[COL_MACHINE], '%s\\%s\\%d' % (
      machine[COL_ADDRESS].replace(':', ''),
      machine[COL_DESTINATION],
      machine[COL_PORTNR])
    )
  # Save changes
  filename = open(os.path.expanduser(CONFIG_FILE), mode='w')
  config.write(filename)
  filename.close()

def loadConfig():
  "Load configuration for window and machines"
  config = ConfigParser.RawConfigParser()
  # Allow loading in case sensitive
  config.optionxform = str
  if os.path.exists(os.path.expanduser(CONFIG_FILE)):
    config.read(os.path.expanduser(CONFIG_FILE))

    # Main window settings
    if config.has_section(SECTION_MAINWIN):
      # Move window to saved position
      if config.has_option(SECTION_MAINWIN, 'left'):
        position_left = config.getint(SECTION_MAINWIN, 'left')
        if config.has_option(SECTION_MAINWIN, 'top'):
          position_top = config.getint(SECTION_MAINWIN, 'top')
        winMain.move(position_left, position_top)
      # Set size to saved size
      if config.has_option(SECTION_MAINWIN, 'width'):
        position_width = config.getint(SECTION_MAINWIN, 'width')
        if config.has_option(SECTION_MAINWIN, 'height'):
          position_height = config.getint(SECTION_MAINWIN, 'height')
        #winMain.resize(position_width, position_height)
        winMain.set_default_size(position_width, position_height)

    # Hosts settings
    if config.has_section(SECTION_HOSTS):
      for machine in config.items(SECTION_HOSTS):
        machine = ('%s\\%s\\255.255.255.255\\9' % machine).split('\\', 4)
        modelMachines.append([
          False,
          machine[0],
          formatMAC(machine[1]),
          get_request_type(machine[2]),
          machine[2],
          int(machine[3])
        ])
    
def wake_on_lan(macaddress, destination, portnr):
  "Turn on remote machine using WOL."
  logText('turning on: %s through %s using port number %d' % (
    macaddress, destination, portnr))
  # Magic packet (6 times FF + 16 times MAC address)
  packet = 'FF' * 6 + macaddress.replace(':', '') * 16
  data = []
  for i in xrange(0, len(packet), 2):
    data.append(struct.pack('B', int(packet[i:i+2], 16)))

  # Send magic packet to the destination
  logText('sending packet %s [%d/%d]\n' % (
    packet, len(packet), len(data)))
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  if destination == '255.255.255.255':
    destination = '<broadcast>'
  sock.sendto(''.join(data), (destination, portnr))

def on_winMain_delete_event(widget, data=None):
  "Save configuration then close the windows and gtk main loop"
  saveConfig()
  dlgMachine.destroy()
  gtk.main_quit()
  return 0

def on_btnWake_clicked(widget, data=None):
  "Awake the selected machines"
  for machine in modelMachines:
    if machine[COL_SELECTED]:
      wake_on_lan(
        machine[COL_ADDRESS],
        machine[COL_DESTINATION],
        machine[COL_PORTNR]
      )

def on_btnAdd_clicked(widget, data=None):
  "Add a new empty machine"
  addMachine('', '', '255.255.255.255', 9)

def on_btnEdit_clicked(widget, data=None):
  "Edit the selected machine"
  selected = tvwMachines.get_selection().get_selected()[1]
  if selected:
    iter = modelMachines[selected]
    if showMachineDialog(
      False,
      iter[COL_MACHINE],
      iter[COL_ADDRESS],
      iter[COL_DESTINATION],
      iter[COL_PORTNR]
    ) == gtk.RESPONSE_OK:
      # Edit information
      iter[COL_MACHINE] = txtMachineName.get_text()
      iter[COL_ADDRESS] = formatMAC(txtMACAddress.get_text())
      iter[COL_REQTYPE] = get_request_type(txtHostAddress.get_text())
      iter[COL_DESTINATION] = txtHostAddress.get_text()
      iter[COL_PORTNR] = spinPortNumber.get_value_as_int()

def on_btnDelete_clicked(widget, data=None):
  "Delete the selected machine"
  selected = tvwMachines.get_selection().get_selected()[1]
  if selected:
    # Ask confirm to delete the selected machine
    dialog = gtk.MessageDialog(parent=None, flags=gtk.DIALOG_MODAL,
      type=gtk.MESSAGE_QUESTION, buttons=gtk.BUTTONS_YES_NO,
      message_format=_('Are you sure you want to remove the selected machine?')
    )
    dialog.set_title(_('Delete machine'))
    dialog.set_default_response(gtk.RESPONSE_NO)
    dialog.set_icon_from_file(APP_LOGO)
    if dialog.run() == gtk.RESPONSE_YES:
      # Response was yes
      modelMachines.remove(selected)
    dialog.destroy()

def on_btnAbout_clicked(widget, data=None):
  "Shows the about dialog"
  about = gtk.AboutDialog()
  about.set_program_name(APP_TITLE)
  about.set_version(APP_VERSION)
  about.set_comments(_('A GTK+ utility to awake turned off computers through '
    'the Wake on LAN feature.'))
  about.set_icon_from_file(APP_LOGO)
  about.set_logo(gtk.gdk.pixbuf_new_from_file(APP_LOGO))
  about.set_copyright('Copyright 2009-2010 Fabio Castelli')
  about.set_translator_credits(readTextFile(__searchPath('doc','translators')))
  about.set_license(readTextFile(__searchPath('doc','copyright')))
  about.set_website_label(APP_TITLE)
  gtk.about_dialog_set_url_hook(lambda url, data=None: url)
  about.set_website('http://code.google.com/p/gwakeonlan/')
  about.set_authors(['Fabio Castelli <muflone@vbsimple.net>',
    'http://www.ubuntutrucchi.it'])
  about.run()
  about.destroy()

def on_cellSelected_toggled(renderer, path, data=None):
  "Select or deselect an item"
  modelMachines[path][COL_SELECTED] = not modelMachines[path][COL_SELECTED]

def on_radioRequest_toggled(widget, data=None):
  "Activates the host and port number only for internet request type"
  active = radioRequestInternet.get_active()
  lblHostAddress.set_sensitive(active)
  txtHostAddress.set_sensitive(active)

def addMachine(machine, mac, destination, portnr):
  "Show the dialog to add a machine with specified arguments"
  if showMachineDialog(True, machine, mac, destination, portnr) == gtk.RESPONSE_OK:
    modelMachines.append([
      False,
      txtMachineName.get_text(),
      formatMAC(txtMACAddress.get_text()),
      get_request_type(txtHostAddress.get_text()),
      txtHostAddress.get_text(),
      spinPortNumber.get_value_as_int()
    ])
  
def on_btnAdd_show_menu(widget, data=None):
  "Detect machines from the arp cache"
  # Remove previous detected addresses
  for item in menuDetected.get_children():
    menuDetected.remove(item)
  detected_addresses.clear()
  # Read ARP cache file
  if os.path.isfile(ARP_CACHE_FILENAME):
    try:
      arpf = open(ARP_CACHE_FILENAME, 'r')

      # Skip first and last line
      for line in arpf.readlines()[1:]:
        if line:
          # Add IP address
          logText('arp line:\n%s' % line, VERBOSE_LEVEL_MAX)
          menu_item = gtk.MenuItem(line[:17].rstrip())
          menu_item.show()
          menu_item.connect('activate', on_menuitemMachineDetected_activate)
          detected_ip = line[:17].rstrip()
          detected_mac = line[41:58]
          logText('discovered %s with address %s' % (
            detected_ip, detected_mac))
          detected_addresses[detected_ip] = (menu_item, detected_mac)
          menuDetected.append(menu_item)
      arpf.close()
    except:
      logText('unable to read from %s' % ARP_CACHE_FILENAME)
  # If no machines are detected add a disabled caption
  if not menuDetected.get_children():
    menu_item = gtk.MenuItem(_('No machines detected'))
    menu_item.set_sensitive(False)
    menu_item.show()
    menuDetected.append(menu_item)
  
def on_menuitemMachineDetected_activate(widget, data=None):
  "Add a detected machine from the arp cache"
  ip_address = widget.get_children()[0].get_label()
  mac_address = detected_addresses[ip_address][1].upper()
  addMachine(ip_address, mac_address, '255.255.255.255', 9)

def fixColumns():
  "Fix column properties not supported by Glade 3.6.3"
  # Column titles were not marked as translatable so they got excluded from
  # intltool-extract
  # Moreover glade 3.6.3 has no support for sort_column_id
  column = gw('columnSelected')
  column.set_title('')
  column.set_sort_column_id(COL_SELECTED)

  column = gw('columnMachine')
  column.set_title(_('Machine name'))
  column.set_sort_column_id(COL_MACHINE)

  column = gw('columnMacAddress')
  column.set_title(_('MAC address'))
  column.set_sort_column_id(COL_ADDRESS)

  column = gw('columnRequestType')
  column.set_title(_('Request type'))
  column.set_sort_column_id(COL_REQTYPE)

  column = gw('columnDestination')
  column.set_title(_('Destination'))
  column.set_sort_column_id(COL_DESTINATION)

  column = gw('columnPortNr')
  column.set_title(_('Port NR'))
  column.set_sort_column_id(COL_PORTNR)
  # Doesn't work at all, neither in glade interface, maybe a bug?
  # cell = gw('columnPortNr').get_cell_renderers()[0]
  # cell.set_property('xalign', 0.50)
  # print gw('columnPortNr').get_cell_renderers()[0].get_property('xalign')
  #
  

# Command line options and arguments
parser = OptionParser(usage='usage: %prog [options]')
parser.set_defaults(verbose_level=VERBOSE_LEVEL_NORMAL)
parser.add_option('-v', '--verbose', dest='verbose_level',
                  action='store_const', const=VERBOSE_LEVEL_MAX,
                  help='show error and information messages')
parser.add_option('-q', '--quiet', dest='verbose_level',
                  action='store_const', const=VERBOSE_LEVEL_QUIET,
                  help='hide error and information messages')
(options, args) = parser.parse_args()

# Signals handlers
signals = {
  'on_winMain_delete_event': on_winMain_delete_event,
  'on_btnWake_clicked': on_btnWake_clicked,
  'on_btnAdd_clicked': on_btnAdd_clicked,
  'on_btnEdit_clicked': on_btnEdit_clicked,
  'on_btnDelete_clicked': on_btnDelete_clicked,
  'on_btnAdd_show_menu': on_btnAdd_show_menu,
  'on_menuitemMachineDetected_activate': on_menuitemMachineDetected_activate,
  'on_btnAbout_clicked': on_btnAbout_clicked,
  'on_radioRequest_toggled': on_radioRequest_toggled,
  'on_cellSelected_toggled': on_cellSelected_toggled
}

# Load domain for translation
for module in (gettext, locale):
  module.bindtextdomain(APP_NAME, __searchPath('locale'))
  module.textdomain(APP_NAME)

# Load interfaces
builder = gtk.Builder()
builder.set_translation_domain(APP_NAME)
builder.add_from_file(__searchPath('ui', '%s.glade' % APP_NAME))
builder.connect_signals(signals)
gw = builder.get_object

# Main window
winMain = gw('winMain')
winMain.set_icon_from_file(APP_LOGO)
tvwMachines = gw('tvwMachines')
btnAdd = gw('btnAdd')
menuDetected = gtk.Menu()
btnAdd.set_menu(menuDetected)

detected_addresses = {}
modelMachines = gw('modelMachines')

# Machine dialog
dlgMachine = gw('dlgMachine')
dlgMachine.set_icon_from_file(APP_LOGO)
txtMachineName = gw('txtMachineName')
txtMACAddress = gw('txtMACAddress')
lblHostAddress = gw('lblHostAddress')
txtHostAddress = gw('txtHostAddress')
lblPortNumber = gw('lblPortNumber')
spinPortNumber = gw('spinPortNumber')
radioRequestLocal = gw('radioRequestLocal')
radioRequestInternet = gw('radioRequestInternet')
lblError = gw('lblError')

dlgMachine.set_default_response(-5)
txtMachineName.set_activates_default(True)
txtMACAddress.set_activates_default(True)
spinPortNumber.set_activates_default(True)
txtHostAddress.set_activates_default(True)

# Load configuration
fixColumns()
loadConfig()
tvwMachines.set_model(modelMachines)
# Auto-resize columns to the content
tvwMachines.realize()
tvwMachines.columns_autosize()
modelMachines.set_sort_column_id(COL_MACHINE, gtk.SORT_ASCENDING)
winMain.show()
gtk.main()
