From d6ddf348a3b53c3b72ec4fd80c22e1379f0dba73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Mart=C3=AD=20Gamboa?= Date: Thu, 9 Jul 2009 10:47:26 +0000 Subject: [PATCH] wader-core import --- CHANGELOG | 215 ++++ LICENSE | 278 ++++++ README | 95 ++ bin/wader-core-ctl | 41 + bin/wader-resolvconf-helper | 5 + contrib/osxserialports/PKG-INFO | 16 + contrib/osxserialports/osxserialportsmodule.c | 200 ++++ core-tap.py | 43 + debian/changelog | 69 ++ debian/compat | 1 + debian/control | 23 + debian/copyright | 15 + debian/rules | 44 + ...ader-core-restart-required.update-notifier | 8 + debian/wader-core.install | 2 + debian/wader-core.postinst | 77 ++ debian/wader-core.postrm | 11 + debian/wader-core.preinst | 15 + debian/wader-core.prerm | 22 + doc/Makefile | 56 ++ doc/_static/wader.css | 1 + doc/conf.py | 131 +++ doc/contents.rst | 31 + doc/devel/add-new-device.rst | 151 +++ doc/devel/add-new-os.rst | 40 + doc/devel/images/network_registration.png | Bin 0 -> 21356 bytes doc/devel/images/overview.png | Bin 0 -> 27955 bytes doc/devel/images/overview.svg | 436 ++++++++ doc/devel/index.rst | 16 + doc/devel/overview.rst | 45 + doc/devel/usage.rst | 316 ++++++ doc/glossary.rst | 48 + doc/modules/_dbus.rst | 18 + doc/modules/aterrors.rst | 175 ++++ doc/modules/command.rst | 18 + doc/modules/config.rst | 11 + doc/modules/contact.rst | 13 + doc/modules/daemon.rst | 31 + doc/modules/dialer.rst | 22 + doc/modules/dialers/hsolink.rst | 13 + doc/modules/dialers/nm_dialer.rst | 13 + doc/modules/dialers/wvdial.rst | 25 + doc/modules/encoding.rst | 21 + doc/modules/exceptions.rst | 20 + doc/modules/exported.rst | 32 + doc/modules/hardware/base.rst | 16 + doc/modules/hardware/huawei.rst | 27 + doc/modules/hardware/novatel.rst | 15 + doc/modules/hardware/option.rst | 31 + doc/modules/hardware/sierra.rst | 15 + doc/modules/hardware/sonnyericsson.rst | 11 + doc/modules/hardware/zte.rst | 15 + doc/modules/interfaces.rst | 146 +++ doc/modules/keyring.rst | 34 + doc/modules/middleware.rst | 16 + doc/modules/netspeed.rst | 16 + doc/modules/oal.rst | 10 + doc/modules/oses/linux.rst | 25 + doc/modules/oses/osx.rst | 17 + doc/modules/plugin.rst | 22 + doc/modules/profile.rst | 17 + doc/modules/protocol.rst | 17 + doc/modules/secrets.rst | 10 + doc/modules/serialport.rst | 17 + doc/modules/shell.rst | 10 + doc/modules/sim.rst | 13 + doc/modules/sms.rst | 25 + doc/modules/startup.rst | 28 + doc/modules/utils.rst | 33 + doc/user/images/add-contact.png | Bin 0 -> 13148 bytes doc/user/images/contacts-main.png | Bin 0 -> 22286 bytes doc/user/images/delete-contacts.png | Bin 0 -> 26802 bytes doc/user/images/delete-sms.png | Bin 0 -> 27854 bytes doc/user/images/edit-contact.png | Bin 0 -> 21665 bytes doc/user/images/new-profile.png | Bin 0 -> 45219 bytes doc/user/images/pin-required.png | Bin 0 -> 13001 bytes doc/user/images/search-contacts.png | Bin 0 -> 26569 bytes doc/user/images/select-number.png | Bin 0 -> 13988 bytes doc/user/images/send-sms.png | Bin 0 -> 12425 bytes doc/user/images/sms-received.png | Bin 0 -> 12746 bytes doc/user/index.rst | 12 + doc/user/tutorial.rst | 367 +++++++ ez_setup.py | 228 +++++ plugins/__init__.py | 0 plugins/devices/__init__.py | 0 plugins/devices/huawei_e169.py | 36 + plugins/devices/huawei_e17X.py | 55 ++ plugins/devices/huawei_e180.py | 33 + plugins/devices/huawei_e220.py | 68 ++ plugins/devices/huawei_e270.py | 35 + plugins/devices/huawei_e272.py | 35 + plugins/devices/huawei_e620.py | 51 + plugins/devices/huawei_e660.py | 33 + plugins/devices/huawei_e660a.py | 33 + plugins/devices/huawei_e870.py | 34 + plugins/devices/huawei_em730v.py | 33 + plugins/devices/huawei_exxx.py | 93 ++ plugins/devices/huawei_k3520.py | 58 ++ plugins/devices/novatel_eu740.py | 33 + plugins/devices/novatel_eu870d.py | 36 + plugins/devices/novatel_mc990d.py | 52 + plugins/devices/novatel_ovation.py | 38 + plugins/devices/novatel_s720.py | 35 + plugins/devices/novatel_u630.py | 34 + plugins/devices/novatel_u740.py | 33 + plugins/devices/novatel_xu870.py | 33 + plugins/devices/option_colt.py | 108 ++ plugins/devices/option_etna.py | 74 ++ plugins/devices/option_globesurfericon.py | 37 + plugins/devices/option_gtfusion.py | 40 + plugins/devices/option_gtfusionquadlite.py | 37 + plugins/devices/option_gtm378.py | 44 + plugins/devices/option_gtmax36.py | 35 + plugins/devices/option_icon225.py | 36 + plugins/devices/option_icon401.py | 36 + plugins/devices/option_k3760.py | 53 + plugins/devices/option_nozomi.py | 35 + plugins/devices/sierrawireless_850.py | 40 + plugins/devices/sierrawireless_875.py | 39 + plugins/devices/sonyericsson_k610i.py | 52 + plugins/devices/sonyericsson_k618i.py | 35 + plugins/devices/zte_k3520.py | 38 + plugins/devices/zte_k3565.py | 42 + plugins/devices/zte_mf620.py | 34 + plugins/devices/zte_mf632.py | 33 + plugins/devices/zte_mf6xx.py | 46 + plugins/oses/__init__.py | 0 plugins/oses/debian.py | 34 + plugins/oses/fedora.py | 43 + plugins/oses/freebsd.py | 28 + plugins/oses/osx.py | 23 + plugins/oses/suse.py | 39 + plugins/oses/ubuntu.py | 71 ++ resources/config/10-wader-modems.fdi | 42 + resources/config/95wader-down | 24 + resources/config/95wader-up | 42 + resources/config/huawei-E169.conf | 5 + resources/config/novatel-MC950D.conf | 6 + resources/config/option-icon-225.conf | 5 + resources/config/wvdial.conf.tpl | 20 + .../dbus/org.freedesktop.ModemManager.conf | 38 + .../dbus/org.freedesktop.ModemManager.service | 4 + resources/extra/__init__.py | 0 resources/extra/networks.py | 374 +++++++ resources/rpm/wader.spec | 172 ++++ resources/udev/99-huawei-e169.rules | 13 + resources/udev/99-novatel-eu870d.rules | 15 + resources/udev/99-novatel-mc950d.rules | 13 + resources/udev/99-novatel-mc990d.rules | 1 + resources/udev/99-option-icon-225.rules | 13 + setup.py | 116 +++ wader/__init__.py | 21 + wader/_version.py | 27 + wader/common/__init__.py | 19 + wader/common/_dbus.py | 151 +++ wader/common/_gconf.py | 57 ++ wader/common/aterrors.py | 537 ++++++++++ wader/common/command.py | 271 +++++ wader/common/config.py | 65 ++ wader/common/consts.py | 148 +++ wader/common/contact.py | 58 ++ wader/common/daemon.py | 200 ++++ wader/common/dialer.py | 337 +++++++ wader/common/dialers/__init__.py | 21 + wader/common/dialers/hsolink.py | 74 ++ wader/common/dialers/nm_dialer.py | 110 +++ wader/common/dialers/wvdial.py | 337 +++++++ wader/common/encoding.py | 83 ++ wader/common/exceptions.py | 44 + wader/common/exported.py | 794 +++++++++++++++ wader/common/hardware/__init__.py | 21 + wader/common/hardware/base.py | 120 +++ wader/common/hardware/huawei.py | 304 ++++++ wader/common/hardware/novatel.py | 76 ++ wader/common/hardware/option.py | 321 ++++++ wader/common/hardware/sierra.py | 112 +++ wader/common/hardware/sonyericsson.py | 31 + wader/common/hardware/zte.py | 157 +++ wader/common/interfaces.py | 190 ++++ wader/common/keyring.py | 351 +++++++ wader/common/middleware.py | 929 ++++++++++++++++++ wader/common/netspeed.py | 108 ++ wader/common/oal.py | 43 + wader/common/oses/__init__.py | 20 + wader/common/oses/bsd.py | 50 + wader/common/oses/linux.py | 581 +++++++++++ wader/common/oses/osx.py | 120 +++ wader/common/oses/unix.py | 38 + wader/common/persistent.py | 92 ++ wader/common/plugin.py | 238 +++++ wader/common/profile.py | 471 +++++++++ wader/common/protocol.py | 783 +++++++++++++++ wader/common/runtime.py | 37 + wader/common/secrets.py | 99 ++ wader/common/serialport.py | 78 ++ wader/common/shell.py | 41 + wader/common/signals.py | 52 + wader/common/sim.py | 130 +++ wader/common/sms.py | 151 +++ wader/common/startup.py | 205 ++++ wader/common/statem/__init__.py | 22 + wader/common/statem/auth.py | 219 +++++ wader/common/statem/networkreg.py | 286 ++++++ wader/common/statem/simple.py | 153 +++ wader/common/utils.py | 156 +++ wader/contrib/__init__.py | 0 wader/contrib/aes.py | 607 ++++++++++++ wader/contrib/ifconfig.py | 67 ++ wader/contrib/tail.py | 131 +++ wader/plugins/__init__.py | 26 + wader/test/__init__.py | 19 + wader/test/test_aterrors.py | 49 + wader/test/test_command.py | 248 +++++ wader/test/test_dbus.py | 681 +++++++++++++ wader/test/test_dialer.py | 156 +++ wader/test/test_encoding.py | 46 + wader/test/test_netspeed.py | 33 + wader/test/test_persistent.py | 57 ++ wader/test/test_simprotocol.py | 428 ++++++++ wader/test/test_utils.py | 114 +++ 220 files changed, 19783 insertions(+) create mode 100644 CHANGELOG create mode 100644 LICENSE create mode 100644 README create mode 100755 bin/wader-core-ctl create mode 100755 bin/wader-resolvconf-helper create mode 100644 contrib/osxserialports/PKG-INFO create mode 100644 contrib/osxserialports/osxserialportsmodule.c create mode 100644 core-tap.py create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/rules create mode 100644 debian/wader-core-restart-required.update-notifier create mode 100644 debian/wader-core.install create mode 100644 debian/wader-core.postinst create mode 100644 debian/wader-core.postrm create mode 100644 debian/wader-core.preinst create mode 100644 debian/wader-core.prerm create mode 100644 doc/Makefile create mode 100644 doc/_static/wader.css create mode 100644 doc/conf.py create mode 100644 doc/contents.rst create mode 100644 doc/devel/add-new-device.rst create mode 100644 doc/devel/add-new-os.rst create mode 100644 doc/devel/images/network_registration.png create mode 100644 doc/devel/images/overview.png create mode 100644 doc/devel/images/overview.svg create mode 100644 doc/devel/index.rst create mode 100644 doc/devel/overview.rst create mode 100644 doc/devel/usage.rst create mode 100644 doc/glossary.rst create mode 100644 doc/modules/_dbus.rst create mode 100644 doc/modules/aterrors.rst create mode 100644 doc/modules/command.rst create mode 100644 doc/modules/config.rst create mode 100644 doc/modules/contact.rst create mode 100644 doc/modules/daemon.rst create mode 100644 doc/modules/dialer.rst create mode 100644 doc/modules/dialers/hsolink.rst create mode 100644 doc/modules/dialers/nm_dialer.rst create mode 100644 doc/modules/dialers/wvdial.rst create mode 100644 doc/modules/encoding.rst create mode 100644 doc/modules/exceptions.rst create mode 100644 doc/modules/exported.rst create mode 100644 doc/modules/hardware/base.rst create mode 100644 doc/modules/hardware/huawei.rst create mode 100644 doc/modules/hardware/novatel.rst create mode 100644 doc/modules/hardware/option.rst create mode 100644 doc/modules/hardware/sierra.rst create mode 100644 doc/modules/hardware/sonnyericsson.rst create mode 100644 doc/modules/hardware/zte.rst create mode 100644 doc/modules/interfaces.rst create mode 100644 doc/modules/keyring.rst create mode 100644 doc/modules/middleware.rst create mode 100644 doc/modules/netspeed.rst create mode 100644 doc/modules/oal.rst create mode 100644 doc/modules/oses/linux.rst create mode 100644 doc/modules/oses/osx.rst create mode 100644 doc/modules/plugin.rst create mode 100644 doc/modules/profile.rst create mode 100644 doc/modules/protocol.rst create mode 100644 doc/modules/secrets.rst create mode 100644 doc/modules/serialport.rst create mode 100644 doc/modules/shell.rst create mode 100644 doc/modules/sim.rst create mode 100644 doc/modules/sms.rst create mode 100644 doc/modules/startup.rst create mode 100644 doc/modules/utils.rst create mode 100644 doc/user/images/add-contact.png create mode 100644 doc/user/images/contacts-main.png create mode 100644 doc/user/images/delete-contacts.png create mode 100644 doc/user/images/delete-sms.png create mode 100644 doc/user/images/edit-contact.png create mode 100644 doc/user/images/new-profile.png create mode 100644 doc/user/images/pin-required.png create mode 100644 doc/user/images/search-contacts.png create mode 100644 doc/user/images/select-number.png create mode 100644 doc/user/images/send-sms.png create mode 100644 doc/user/images/sms-received.png create mode 100644 doc/user/index.rst create mode 100644 doc/user/tutorial.rst create mode 100644 ez_setup.py create mode 100644 plugins/__init__.py create mode 100644 plugins/devices/__init__.py create mode 100644 plugins/devices/huawei_e169.py create mode 100644 plugins/devices/huawei_e17X.py create mode 100644 plugins/devices/huawei_e180.py create mode 100644 plugins/devices/huawei_e220.py create mode 100644 plugins/devices/huawei_e270.py create mode 100644 plugins/devices/huawei_e272.py create mode 100644 plugins/devices/huawei_e620.py create mode 100644 plugins/devices/huawei_e660.py create mode 100644 plugins/devices/huawei_e660a.py create mode 100644 plugins/devices/huawei_e870.py create mode 100644 plugins/devices/huawei_em730v.py create mode 100644 plugins/devices/huawei_exxx.py create mode 100644 plugins/devices/huawei_k3520.py create mode 100644 plugins/devices/novatel_eu740.py create mode 100644 plugins/devices/novatel_eu870d.py create mode 100644 plugins/devices/novatel_mc990d.py create mode 100644 plugins/devices/novatel_ovation.py create mode 100644 plugins/devices/novatel_s720.py create mode 100644 plugins/devices/novatel_u630.py create mode 100644 plugins/devices/novatel_u740.py create mode 100644 plugins/devices/novatel_xu870.py create mode 100644 plugins/devices/option_colt.py create mode 100644 plugins/devices/option_etna.py create mode 100644 plugins/devices/option_globesurfericon.py create mode 100644 plugins/devices/option_gtfusion.py create mode 100644 plugins/devices/option_gtfusionquadlite.py create mode 100644 plugins/devices/option_gtm378.py create mode 100644 plugins/devices/option_gtmax36.py create mode 100644 plugins/devices/option_icon225.py create mode 100644 plugins/devices/option_icon401.py create mode 100644 plugins/devices/option_k3760.py create mode 100644 plugins/devices/option_nozomi.py create mode 100644 plugins/devices/sierrawireless_850.py create mode 100644 plugins/devices/sierrawireless_875.py create mode 100644 plugins/devices/sonyericsson_k610i.py create mode 100644 plugins/devices/sonyericsson_k618i.py create mode 100644 plugins/devices/zte_k3520.py create mode 100644 plugins/devices/zte_k3565.py create mode 100644 plugins/devices/zte_mf620.py create mode 100644 plugins/devices/zte_mf632.py create mode 100644 plugins/devices/zte_mf6xx.py create mode 100644 plugins/oses/__init__.py create mode 100644 plugins/oses/debian.py create mode 100644 plugins/oses/fedora.py create mode 100644 plugins/oses/freebsd.py create mode 100644 plugins/oses/osx.py create mode 100644 plugins/oses/suse.py create mode 100644 plugins/oses/ubuntu.py create mode 100644 resources/config/10-wader-modems.fdi create mode 100755 resources/config/95wader-down create mode 100755 resources/config/95wader-up create mode 100644 resources/config/huawei-E169.conf create mode 100644 resources/config/novatel-MC950D.conf create mode 100644 resources/config/option-icon-225.conf create mode 100644 resources/config/wvdial.conf.tpl create mode 100644 resources/dbus/org.freedesktop.ModemManager.conf create mode 100644 resources/dbus/org.freedesktop.ModemManager.service create mode 100644 resources/extra/__init__.py create mode 100644 resources/extra/networks.py create mode 100644 resources/rpm/wader.spec create mode 100644 resources/udev/99-huawei-e169.rules create mode 100644 resources/udev/99-novatel-eu870d.rules create mode 100644 resources/udev/99-novatel-mc950d.rules create mode 100644 resources/udev/99-novatel-mc990d.rules create mode 100644 resources/udev/99-option-icon-225.rules create mode 100644 setup.py create mode 100644 wader/__init__.py create mode 100644 wader/_version.py create mode 100644 wader/common/__init__.py create mode 100644 wader/common/_dbus.py create mode 100644 wader/common/_gconf.py create mode 100644 wader/common/aterrors.py create mode 100644 wader/common/command.py create mode 100644 wader/common/config.py create mode 100644 wader/common/consts.py create mode 100644 wader/common/contact.py create mode 100644 wader/common/daemon.py create mode 100644 wader/common/dialer.py create mode 100644 wader/common/dialers/__init__.py create mode 100644 wader/common/dialers/hsolink.py create mode 100644 wader/common/dialers/nm_dialer.py create mode 100644 wader/common/dialers/wvdial.py create mode 100644 wader/common/encoding.py create mode 100644 wader/common/exceptions.py create mode 100644 wader/common/exported.py create mode 100644 wader/common/hardware/__init__.py create mode 100644 wader/common/hardware/base.py create mode 100644 wader/common/hardware/huawei.py create mode 100644 wader/common/hardware/novatel.py create mode 100644 wader/common/hardware/option.py create mode 100644 wader/common/hardware/sierra.py create mode 100644 wader/common/hardware/sonyericsson.py create mode 100644 wader/common/hardware/zte.py create mode 100644 wader/common/interfaces.py create mode 100644 wader/common/keyring.py create mode 100644 wader/common/middleware.py create mode 100644 wader/common/netspeed.py create mode 100644 wader/common/oal.py create mode 100644 wader/common/oses/__init__.py create mode 100644 wader/common/oses/bsd.py create mode 100644 wader/common/oses/linux.py create mode 100644 wader/common/oses/osx.py create mode 100644 wader/common/oses/unix.py create mode 100644 wader/common/persistent.py create mode 100644 wader/common/plugin.py create mode 100644 wader/common/profile.py create mode 100644 wader/common/protocol.py create mode 100644 wader/common/runtime.py create mode 100644 wader/common/secrets.py create mode 100644 wader/common/serialport.py create mode 100644 wader/common/shell.py create mode 100644 wader/common/signals.py create mode 100644 wader/common/sim.py create mode 100644 wader/common/sms.py create mode 100644 wader/common/startup.py create mode 100644 wader/common/statem/__init__.py create mode 100644 wader/common/statem/auth.py create mode 100644 wader/common/statem/networkreg.py create mode 100644 wader/common/statem/simple.py create mode 100644 wader/common/utils.py create mode 100644 wader/contrib/__init__.py create mode 100644 wader/contrib/aes.py create mode 100644 wader/contrib/ifconfig.py create mode 100644 wader/contrib/tail.py create mode 100644 wader/plugins/__init__.py create mode 100644 wader/test/__init__.py create mode 100644 wader/test/test_aterrors.py create mode 100644 wader/test/test_command.py create mode 100644 wader/test/test_dbus.py create mode 100644 wader/test/test_dialer.py create mode 100644 wader/test/test_encoding.py create mode 100644 wader/test/test_netspeed.py create mode 100644 wader/test/test_persistent.py create mode 100644 wader/test/test_simprotocol.py create mode 100644 wader/test/test_utils.py diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..74636cd --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,215 @@ +======================================= +Wader-0.3.6 +Overview of changes since Wader-0.3.5.2 +======================================= + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * DNS update mechanisms have been harmonized, see #154. On Ubuntu + hardy, wader-core does not depend on resolvconf anymore as it + causes conflicts with the stock NetworkManager. + * New device supported: Novatel MC990D + * Start and restart scripts merged into one: wader-core-ctl + +* wader-gtk + * Fix PUK/PUK2 handling, see #148 + * Make sure profiles work with GConf-DBus, see #144 + +* wader-doc + * Documentation migrated to Sphinx and revised + * wader-doc is no longer built by default, thus saving us from + maintaining many, buggy, dependencies. + +===================================== +Wader-0.3.5.2 +Overview of changes since Wader-0.3.5 +===================================== + +This is a new nano stable release of Wader. wader-0.3.5.1 was quickly +replaced by wader-0.3.5.2 as it didn't built on hardy. List of changes: + +* wader-core + * Some HSO bugs introduced in 0.3.{4,5} have been fixed. This is what + you get when you don't test a release with every supported device. + * Do not execute set_network_type if network_type is None + * wader._version allows nano releases + * wader-core depends on resolvconf as its not included by default + on Hardy. + +* wader-gtk + * Handle NoKeyringDaemonError exceptions, not finished. #149 + +===================================== +Wader-0.3.5 +Overview of changes since Wader-0.3.4 +===================================== + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * DBusDevicePlugin merged into DevicePlugin + * Separate better the ModemManager and Wader exceptions + * get_radio_status return value fixed + * Some fixes for hotplugging events + * Huawei: + * get_driver_name fixed for Nozomi + * use HuaweiE620's cmd_dict rather than Huawei's + * Handle ^MODE: 0,2 in Huawei + * Handle error in ^CURC=1 command + * Do not use os.system but subprocess.call instead + * Set a registering lock to avoid multiple attempts of registration + * Add the U1900 band + * org.freedesktop.ModemManager.Modem.Simple interface implemented + (and untested) + * Device creation time has been reduced + * Fix "undefined reference to ser" while probing ports + * Unused stuff removed + +* wader-gtk + * Many profiles bugs fixed + * Only ask for profile when self.profile is None + * Add Ctrl+Q accelerator to log window + * Stop throbber if device is not present + * Remove standard gtk symbols from translation + +===================================== +Wader-0.3.4 +Overview of changes since Wader-0.3.3 +===================================== + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * WVDIAL: Use either the 'Connected' string or the DNS info to ack that we are + connected. + * Get rid of python-axiom +* wader-gtk + * Handle gracefully errors in SetBand and SetConnectionMode + * Use translated strings for mode changes + +===================================== +Wader-0.3.3 +Overview of changes since Wader-0.3.2 +===================================== + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * Fix initialization routine so it bails with cards that insist on + replying SimBusy to +CPBR=? commands. Next time is necessary will be + requested again and will succeed. + * Use PDP context in HSO dialer + * Only enable/disable radio if necessary + * Some wvdial fixes make it more robust + * CLeanup CGMM response if echo was enabled + * Some ZTE love + * Fix dialup with Option GTM378, it must use the hso dialer. + * Better responsiveness in network registration state machine + * Many, many bug fixes. + +* wader-gtk + * only enable PIN/PUK if what user entered is meaningful + * new wader icon + * make sure bytes are resetted + +===================================== +Wader-0.3.2 +Overview of changes since Wader-0.3.1 +===================================== + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * NetworkManager compatibility has been temporally disabled, our + three target distros ship with different snapshots of NM/MM and + they don't include some later patches required for compatibility. + See [0] for more info. This has the downside of firefox not realizing + that we are connected and it will insist on that we are disconnected. A + simple workaround is to check the "Work offline" checkbox in "File". + * New device added (untested): ZTE MF632 + * NMPPPDDialer now listens to PropertyChanged signals + * Make sure dialers are unexported upon unsuccessful connection attempt. + * wader-core now depends on ozerocdoff. + * Many, many bug fixes. + +* wader-gtk + * Problems with unsigned ints and some DNS have been fixed. + * Get rid of GtkSpinButton warning. + * Handle gracefully unsucessful connection attempts [1]. + * Do not allow Set{Band,ConnectionMode} if we are connected. + +[0] http://trac.warp.es/wader/ticket/133 +[1] http://trac.warp.es/wader/ticket/132 + +===================================== +Wader-0.3.1 +Overview of changes since Wader-0.3.0 +===================================== + +This is a new minor stable release of Wader. List of changes: + +* wader-core + * Really restart the core upon upgrade (Debian/Ubuntu only) + * Plugins are only included once and at /usr/share/wader/plugins. An + upgrade routine has been added to ensure a smooth transition. + * wader-core now mimics the udis that ModemManager uses to export devices. + * wader-core enables/disables radio too now (+CFUN=0,1) + * Use o.fd.MM.Modem rather than o.fd.ModemManager for properties + * OS detection has been improved by not relying on lsb_release + * wader-core runs on python2.6 + * wader-core now depends on python-messaging. A joint project between + Warp (Wader) & OpenShine (MobileManager) to create a solid SMS + encoding/decoding library. + * New models supported: Huawei E169, Huawei E180 + * o.fd.DBus.Properties.GetAll implemented + +* wader-gtk + * The UI has been gettex'd and there are Spanish and French translations + * The SMS/Contacts UI has been polished and some interesting + new features have been added: + * Support for multipart SMS (only sending for now) + * Support for categories (Inbox, Drafts and Sent for now) + * Support for storing SMS and sending it from SIM + * Support for searching contacts + * Support for searching SMS (not enabled unless pygtk >= 2.14.0 + because of a bug on set_visible_func) + * A throbber is shown for every potentially long (IO) operations + * Log window now updates the log every second + * .desktop added for supported RPM systems + * Copyright changed to 'Wader contributors' + * Many, many bugs fixed + +* doc + * User tutorial added + + +===================================== +Wader-0.3.0 +Overview of changes since Wader-0.2.X +===================================== + +This is a new major stable release of Wader. List of changes: + +* wader-core + * Wader is the first project that implements the ModemManager API + apart from MM itself. It implements the whole MM API except for + the new Ericsson's MBM devices (introduced late in the MM + development cycle, will support them as soon as we get our hands + in one of them). For more info check out[0]. + * Wader ditches the session bus and works exclusively on the system bus. + * Dialup support for system with NM 0.6.X, NM0.7 and NM0.7.X. + * Multiple active devices. + * Wader doesn't depends on hsolink any more. + * New devices supported: Option Icon 401, Huawei EM730V, Huawei K3520, + ZTE K3520 and ZTE K3565. + * Initial support for OSX. No dialup, no UI, only DBus functionality. + +* wader-gtk + * Wader now uses gconf to store all its config settings. + * Initial support for SMS/Contacts. The UI is not that great, it will + be improved in next release. + +[0] http://trac.warp.es/wader/wiki/WhatsModemManager + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4eb3f1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,278 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. diff --git a/README b/README new file mode 100644 index 0000000..4371d4b --- /dev/null +++ b/README @@ -0,0 +1,95 @@ +Wader is a 3G daemon accessible via DBus, written in Python and released +under the GPLv2. Wader runs on Linux and OSX. + +Wader's target audience are developers. If you would like your application to +interact with a UMTS device, such as a mobile data card or a mobile phone, +then stop searching! Features: + + * Built upon modern technologies such as dbus and hal + * Service invoked via DBus + * A single process handles n devices + * Dialup via NM 0.7+ (if present) or Wvdial on systems with NM < 0.7 + * Extensible AT Engine + * Pluggable support for devices: Adding support for a new device is + usually a matter of copying an skeleton, changing the device IDs and + dropping the plugin in /usr/share/wader-core/plugins + * A python shell to interact with the device in runtime + +History + +Wader is a fork of the core of Vodafone Mobile Connect Card driver for Linux[0] + +Some of its parts have been completely rewritten and the most juicy bits have +been exported over DBus to allow other applications of the Linux desktop to +use Wader. Wader is the first project (apart from ModemManager itself) that +implements ModemManager's API[1]. This means that NetworkManager 0.7.X/0.8 +will be able to use wader-core to perform operations on devices. + +Supported devices + + * Huawei E170/E172 + * Huawei E620/E660/E660a + * Huawei E220 + * Huawei E270/E272 + * Huawei E870 + * Huawei EM730V + * Huawei K3520 + * Option Nozomi, Etna + * Option Icon 225 + * Option Icon 401 + * Novatel Ovation + +Devices that are known to work although we haven't tested: + + * Huawei E169 + * Huawei E180 + * Novatel U630 (plugin written by Pablo Marti with help from Andrew Gee) + * Novatel U740 (plugin written by Adam King) + * Novatel EU740 + * Novatel XU870 + * Option Colt (It's recommended to disable PIN, it has a rather buggy fw) + * Option GlobeSurfer? Icon (plugin contributed by Simone Tolotti) + * Option GT Fusion (plugin contributed by Stefano Rivera) + * Option GT Fusion Quad Lite (plugin contributed by Stefano Rivera) + * Option GT M 378 + * Option GT Max 3.6 (plugin contributed by kgb0y) + * SierraWireless 850, 875 + * ZTE MF620 + * ZTE K3520, K3565 + +This software should work (in theory) with any device that follows the relevant GSM and 3G specs. Nonetheless, every device is different and it may not work in an untested device. Try it at your own risk. If you speak Python and feel adventurous you could get involved by supporting a new device/distro. +SVN access + +Project SVN: + +svn co http://pubsvn.warp.es/wader/node/core/trunk wader-core-trunk-ro + +Contact the developers if you want commit access + + +LICENSE + +Wader is distributed under the GPLv2. See the LICENSE file for the gory +details. + +FAQ + +0 .- Wader fails horribly with my OS + + Wader has been tested on the following distros: + - Ubuntu 8.04+ + - OpenSUSE 11+ + - Fedora 10+ + + You can find instructions of how to add a new OS/Distro in the doc. + +1 .- Wader fails horribly with my device + + Chances are that your device is a cousin of one of our supported devices. + Adding support for a new device is relatively easy (as long as it behaves), + you can find instructions of how to add a new device in the doc. + + +[0] https://forge.vodafonebetavine.net/projects/vodafonemobilec/ +[1] http://trac.warp.es/wader/wiki/WhatsModemManager + diff --git a/bin/wader-core-ctl b/bin/wader-core-ctl new file mode 100755 index 0000000..2ac9f04 --- /dev/null +++ b/bin/wader-core-ctl @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +import os +from optparse import OptionParser +import sys + +from wader.common.consts import APP_VERSION, LOG_PATH, DATA_DIR, PID_PATH + +from twisted.python.release import sh + +parser = OptionParser() +parser.add_option ("-v", "--version", action="store_true", + dest="show_version", default=False, + help="Show version and exit") +parser.add_option ("-r", "--restart", action="store_true", + dest="should_restart", default=False, + help="Restart wader-core") +parser.add_option ("-s", "--start", action="store_true", + dest="should_start", default=True, + help="Start wader-core") + +options, args = parser.parse_args() + +if options.show_version: + print "%s: version %s" % (os.path.basename(sys.argv[0]), APP_VERSION) + sys.exit(0) + +if options.should_restart: + if os.path.exists('/var/run/wader.pid'): + sh("kill -9 `cat /var/run/wader.pid`") + + sh("dbus-send --system --dest=org.freedesktop.ModemManager /org/freedesktop/ModemManager org.freedesktop.ModemManager.EnumerateDevices") + +elif options.should_start: + if os.path.exists('/tmp/wader.err'): + sh("rm -f /tmp/wader.err") + + tap_path = os.path.join(DATA_DIR, 'core-tap.py') + sh("/usr/bin/twistd --nodaemon --python=%s --logfile=%s " + "--pidfile=%s --reactor=glib2 2> /tmp/wader.err" % (tap_path, LOG_PATH, PID_PATH)) + diff --git a/bin/wader-resolvconf-helper b/bin/wader-resolvconf-helper new file mode 100755 index 0000000..aac81de --- /dev/null +++ b/bin/wader-resolvconf-helper @@ -0,0 +1,5 @@ +#!/bin/bash +# Debian/Ubuntu only helper for setting resolvconf info +# twisted's getProcessValue never returned with this command :? +/sbin/resolvconf -a $1 < $2 +exit 0 diff --git a/contrib/osxserialports/PKG-INFO b/contrib/osxserialports/PKG-INFO new file mode 100644 index 0000000..29a3b95 --- /dev/null +++ b/contrib/osxserialports/PKG-INFO @@ -0,0 +1,16 @@ +Metadata-Version: 1.0 +Name: MacOS_X_SerialPorts +Version: 0.2 +Summary: An extension for detecting available serial modems on MacOS X. +Home-page: http://www.wader-project.org/ +Author: Pablo Marti +Author-email: pmarti@warp.es +License: GNU GPL +Description: An extension for MacOS X + + It allows you to detect all serial modems on OSX. + + Based on Pascal Oberndoerfer's osxserialports module, + placed on the public domain. + +Platform: MacOS diff --git a/contrib/osxserialports/osxserialportsmodule.c b/contrib/osxserialports/osxserialportsmodule.c new file mode 100644 index 0000000..9d05123 --- /dev/null +++ b/contrib/osxserialports/osxserialportsmodule.c @@ -0,0 +1,200 @@ +#include + +#include +#include + +#include + +#include +#include +#include + +static PyObject * GetModemList(io_iterator_t serialPortIterator); +static kern_return_t FindModems(io_iterator_t *matchingServices); +static PyObject * osxserialports_modems(PyObject *self, PyObject *args); + +static PyObject * +GetModemList(io_iterator_t serialPortIterator) +{ + io_object_t deviceService; + int maxPathSize = MAXPATHLEN; + + PyObject *ret = Py_BuildValue("[]"); + + /* Iterate across all devices found. */ + + while ((deviceService = IOIteratorNext(serialPortIterator))) + { + CFTypeRef bsdIOTTYDeviceAsCFString; + CFTypeRef bsdIOTTYBaseNameAsCFString; + CFTypeRef bsdIOTTYSuffixAsCFString; + CFTypeRef bsdCalloutPathAsCFString; + CFTypeRef bsdDialinPathAsCFString; + + Boolean result; + + char name[MAXPATHLEN]; /* MAXPATHLEN = 1024 */ + char base[MAXPATHLEN]; + char suffix[MAXPATHLEN]; + char callout[MAXPATHLEN]; + char dialin[MAXPATHLEN]; + + PyObject *d; + + bsdIOTTYDeviceAsCFString = IORegistryEntryCreateCFProperty(deviceService, + CFSTR(kIOTTYDeviceKey), + kCFAllocatorDefault, + 0); + bsdIOTTYBaseNameAsCFString = IORegistryEntryCreateCFProperty(deviceService, + CFSTR(kIOTTYBaseNameKey), + kCFAllocatorDefault, + 0); + bsdIOTTYSuffixAsCFString = IORegistryEntryCreateCFProperty(deviceService, + CFSTR(kIOTTYSuffixKey), + kCFAllocatorDefault, + 0); + bsdCalloutPathAsCFString = IORegistryEntryCreateCFProperty(deviceService, + CFSTR(kIOCalloutDeviceKey), + kCFAllocatorDefault, + 0); + bsdDialinPathAsCFString = IORegistryEntryCreateCFProperty(deviceService, + CFSTR(kIODialinDeviceKey), + kCFAllocatorDefault, + 0); + + if (bsdIOTTYDeviceAsCFString) + { + result = CFStringGetCString(bsdIOTTYDeviceAsCFString, + name, + maxPathSize, + kCFStringEncodingASCII); + CFRelease(bsdIOTTYDeviceAsCFString); + } + + if (bsdIOTTYBaseNameAsCFString) + { + result = CFStringGetCString(bsdIOTTYBaseNameAsCFString, + base, + maxPathSize, + kCFStringEncodingASCII); + CFRelease(bsdIOTTYBaseNameAsCFString); + } + + if (bsdIOTTYSuffixAsCFString) + { + result = CFStringGetCString(bsdIOTTYSuffixAsCFString, + suffix, + maxPathSize, + kCFStringEncodingASCII); + CFRelease(bsdIOTTYSuffixAsCFString); + } + + if (bsdCalloutPathAsCFString) + { + result = CFStringGetCString(bsdCalloutPathAsCFString, + callout, + maxPathSize, + kCFStringEncodingASCII); + CFRelease(bsdCalloutPathAsCFString); + } + + if (bsdDialinPathAsCFString) + { + result = CFStringGetCString(bsdDialinPathAsCFString, + dialin, + maxPathSize, + kCFStringEncodingASCII); + CFRelease(bsdDialinPathAsCFString); + } + + d = Py_BuildValue("{s:s,s:s,s:s,s:s,s:s}", + "name", name, + "base", base, + "suffix", suffix, + "callout", callout, + "dialin", dialin); + + if (PyList_Append(ret, d)) { + Py_DECREF(d); + goto error; + } else { + Py_DECREF(d); + } + } + +error: + /* Release the io_service_t now that we are done with it. */ + (void) IOObjectRelease(deviceService); + return ret; +} + +static kern_return_t +FindModems(io_iterator_t *matchingServices) +{ + kern_return_t kernResult; + mach_port_t masterPort; + CFMutableDictionaryRef classesToMatch; + + kernResult = IOMasterPort(MACH_PORT_NULL, &masterPort); + if (KERN_SUCCESS != kernResult) + { + /* printf("IOMasterPort returned %d\n", kernResult); */ + goto exit; + } + + classesToMatch = IOServiceMatching(kIOSerialBSDServiceValue); + if (classesToMatch != NULL) + { + CFDictionarySetValue(classesToMatch, + CFSTR(kIOSerialBSDTypeKey), + CFSTR(kIOSerialBSDModemType)); + } + + kernResult = IOServiceGetMatchingServices(masterPort, classesToMatch, matchingServices); + if (KERN_SUCCESS != kernResult) + { + /* printf("IOServiceGetMatchingServices returned %d\n", kernResult); */ + goto exit; + } + +exit: + return kernResult; +} + +static PyObject * +osxserialports_modems(PyObject *self, PyObject *args) +{ + kern_return_t kernResult; + io_iterator_t serialPortIterator; + PyObject *ret = NULL; + + char *argstring; + + if (!PyArg_ParseTuple(args, "", &argstring)) /* No arguments */ + return NULL; + + kernResult = FindModems(&serialPortIterator); + ret = GetModemList(serialPortIterator); + + IOObjectRelease(serialPortIterator); /* Release the iterator. */ + if (EX_OK != kernResult) { + Py_XDECREF(ret); + return NULL; + } else { + return ret; + } +} + +static PyMethodDef +osxserialportsMethods[] = { + {"modems", osxserialports_modems, METH_VARARGS, + "List all serial port modems available on MacOS X."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +void +initosxserialports(void) +{ + (void) Py_InitModule("osxserialports", osxserialportsMethods); +} + diff --git a/core-tap.py b/core-tap.py new file mode 100644 index 0000000..b45257b --- /dev/null +++ b/core-tap.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""tap file for Wader""" + +import locale +# i10n stuff +locale.setlocale(locale.LC_ALL, '') + +from wader.common.consts import APP_NAME +from wader.common.startup import (create_skeleton_and_do_initial_setup, + get_wader_application) +# it will just return if its not necessary +create_skeleton_and_do_initial_setup() + +# access osobj singleton +from wader.common.oal import osobj +if osobj is None: + message = 'OS/Distro not registered' + details = """ +The OS/Distro under which you are running %s +is not registered in the OS database. Check the documentation for what +you can do in order to support your OS/Distro +""" % APP_NAME + raise SystemExit("%s\n%s" % (message, details)) + +application = get_wader_application() + diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..601b343 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,69 @@ +wader-core (0.3.6-1) jaunty; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Tue, 05 May 2009 15:50:31 +0200 + +wader (0.3.5) intrepid; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Mon, 15 Apr 2009 16:09:11 +0100 + +wader (0.3.4) intrepid; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Tue, 3 Mar 2009 16:52:21 +0100 + +wader (0.3.3) intrepid; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Mon, 23 Feb 2009 12:11:29 +0100 + +wader (0.3.2) intrepid; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Thu, 13 Feb 2009 12:01:11 +0100 + +wader (0.3.1) hardy; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Mon, 02 Feb 2009 16:59:13 +0100 + +wader (0.3.0) hardy; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Mon, 1 Dec 2008 12:36:58 +0100 + +wader (0.2.2-1) hardy; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Thu, 17 Jul 2008 18:12:21 +0200 + +wader (0.2.1-1) hardy; urgency=low + + * New upstream version + * Do not include restart-wader-core twice + + -- Pablo Martí Gamboa Thu, 17 Jul 2008 17:17:48 +0200 + + +wader (0.2.0-1) hardy; urgency=low + + * New upstream version + + -- Pablo Martí Gamboa Thu, 17 Jul 2008 15:31:48 +0200 + + +wader (0.1.0-1) hardy; urgency=low + + * Initial Release + * Renamed to wader + + -- Fri, 04 Jul 2008 10:38:41 +0100 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..b8626c4 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +4 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..cd11ea1 --- /dev/null +++ b/debian/control @@ -0,0 +1,23 @@ +Source: wader-core +Section: net +Priority: optional +Maintainer: Pablo Marti Gamboa +Build-Depends: debhelper (>= 5) +Build-Depends-Indep: python-central (>= 0.5), python-setuptools, python-dbus, python-twisted-core +Standards-Version: 3.8.0 +Homepage: http://www.wader-project.org +XS-Python-Version: current + +Package: wader-core +Architecture: all +Replaces: wader-core, vmc-core +Depends: ${python:Depends}, python-twisted-core, python-serial, python-dbus, python-tz, python-messaging, python-epsilon, wvdial, usb-modeswitch, ozerocdoff, resolvconf, eject +Recommends: python-twisted-conch +Conflicts: modem-manager +XB-Python-Version: ${python:Versions} +Description: Internet connection assistant for mobile devices. + Wader is a tool that manages 3G devices and mobile phones offering a dbus + interface so other applications can use its services as connecting to the + Internet, sending SMS, managing contacts, and such. This is the core + package. + . diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..e554338 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,15 @@ +Copyright 2006-2008, Vodafone España S.A. +Copyright 2008-2009, Warp Networks S.L. + +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, 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. + +You should have received a copy of the GNU General Public License with your +Debian GNU system, in /usr/share/common-licenses/GPL. If not, write to the +Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +02110-1301, USA. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..5066594 --- /dev/null +++ b/debian/rules @@ -0,0 +1,44 @@ +#!/usr/bin/make -f +export DH_VERBOSE=1 + +build: build-stamp + +build-stamp: + dh_testdir + python setup.py build + touch build-stamp + +clean: + dh_testdir + dh_testroot + rm -f build-stamp + rm -rf build + -find . -name '*.py[co]' | xargs rm -f + dh_clean + +install: build + dh_testdir + dh_testroot + dh_clean -k + dh_installdirs + python setup.py install --root $(CURDIR)/debian/wader-core + DH_PYCENTRAL=nomove dh_pycentral + dh_install + +# Build architecture-independent files here. +binary-indep: build install + dh_testdir + dh_testroot + dh_installchangelogs CHANGELOG + dh_installdocs README + dh_compress -X.py + dh_fixperms + dh_installdeb + dh_gencontrol + dh_md5sums + dh_builddeb + +# Build architecture-dependent files here. +binary-arch: build install +binary: binary-indep binary-arch +.PHONY: build clean binary-indep binary-arch binary install configure diff --git a/debian/wader-core-restart-required.update-notifier b/debian/wader-core-restart-required.update-notifier new file mode 100644 index 0000000..7946397 --- /dev/null +++ b/debian/wader-core-restart-required.update-notifier @@ -0,0 +1,8 @@ +Name: wader-core restart required +Priority: High +Terminal: False +DontShowAfterReboot: True +DisplayIf: ps aux | grep wader | grep twistd +Command: gksudo /usr/bin/wader-core-ctl --restart +Description: Wader has been upgraded (or reinstalled) and must be restarted. + Please press the command button in order to stop Wader. diff --git a/debian/wader-core.install b/debian/wader-core.install new file mode 100644 index 0000000..a49b97b --- /dev/null +++ b/debian/wader-core.install @@ -0,0 +1,2 @@ +debian/wader-core-restart-required.update-notifier /usr/share/wader-core/ +bin/wader-resolvconf-helper /usr/bin/ diff --git a/debian/wader-core.postinst b/debian/wader-core.postinst new file mode 100644 index 0000000..c6ac806 --- /dev/null +++ b/debian/wader-core.postinst @@ -0,0 +1,77 @@ +#!/bin/sh + +set -e + +UPDATENOTIFIERDIR=/var/lib/update-notifier/user.d +LIBDIR=/usr/share/wader-core +UPDATENOTIFIERTOUCH=/var/lib/update-notifier/dpkg-run-stamp + +fix_peers() +{ + if [ -e /etc/ppp/peers ];then + chown -R :dialout /etc/ppp/peers + chmod -R g+w /etc/ppp/peers + fi +} + +fix_pap() +{ + if [ -e /etc/ppp/pap-secrets ]; then + chown :dialout /etc/ppp/pap-secrets + chmod g+rw /etc/ppp/pap-secrets + fi +} + +fix_chap() +{ + if [ -e /etc/ppp/chap-secrets ]; then + chown :dialout /etc/ppp/chap-secrets + chmod g+rw /etc/ppp/chap-secrets + fi +} + +show_update() +{ + if [ -d $UPDATENOTIFIERDIR ] ; then + if [ `ps aux | grep wader | grep twistd | wc -l` -ne 0 ] ; then + cp -f $LIBDIR/wader-core-restart-required.update-notifier \ + $UPDATENOTIFIERDIR/wader-core-restart-required + # if we don't do this touch it wont work + touch $UPDATENOTIFIERTOUCH + else + rm -f $UPDATENOTIFIERDIR/wader-core-restart-required + fi + fi +} + +reload_udev_rules() +{ + if [ ! -x /sbin/udevcontrol ] ; then + /sbin/udevadm control --reload-rules + else + /sbin/udevcontrol reload_rules + fi +} + +clear_plugin_cache() +{ + rm -rf /usr/share/wader-core/plugins/dropin.cache + python -c "from twisted.plugin import IPlugin, getPlugins;import wader.plugins; list(getPlugins(IPlugin, package=wader.plugins))" +} + +case "$1" in + (configure) + fix_peers + fix_pap + fix_chap + kill -HUP `cat /var/run/dbus/pid` + [ -n "$2" ] && clear_plugin_cache && show_update + ;; +esac + +reload_udev_rules + +exit 0 + +#DEBHELPER# + diff --git a/debian/wader-core.postrm b/debian/wader-core.postrm new file mode 100644 index 0000000..4e19013 --- /dev/null +++ b/debian/wader-core.postrm @@ -0,0 +1,11 @@ +#!/bin/sh -e + +case "$1" in + (remove) + if [ -e /var/run/wader.pid ]; then + kill -9 `cat /var/run/wader.pid` 2>/dev/null || true + fi + ;; +esac + +#DEBHELPER# diff --git a/debian/wader-core.preinst b/debian/wader-core.preinst new file mode 100644 index 0000000..1594534 --- /dev/null +++ b/debian/wader-core.preinst @@ -0,0 +1,15 @@ +#!/bin/sh -e + +case "$1" in + (upgrade) + # kill wader + kill -9 `cat /var/run/wader.pid` 2>/dev/null || true + # clean up + rm /var/run/wader.pid 2>/dev/null || true + # remove traces of old dir + if [ -d /usr/share/wader ]; then + rm -rf /usr/share/wader + fi +esac + +#DEBHELPER# diff --git a/debian/wader-core.prerm b/debian/wader-core.prerm new file mode 100644 index 0000000..3930955 --- /dev/null +++ b/debian/wader-core.prerm @@ -0,0 +1,22 @@ +#!/bin/sh -e + +case "$1" in + (remove) + rm -rf /usr/share/wader-core/plugins/dropin.cache + rm -rf /usr/share/wader-core/plugins + rm -rf /usr/share/wader-core/.setup-done + if [ -d /usr/local/lib/python2.6/dist-packages/wader ]; then + find /usr/local/lib/python2.6/dist-packages/wader -name "*.pyc" 2>/dev/null | xargs rm -rf + fi + if [ -d /usr/lib/python2.5/site-packages/wader ]; then + find /usr/lib/python2.5/site-packages/wader -name "*.pyc" 2>/dev/null | xargs rm -rf + fi + ;; + (purge) + if [ -e /usr/share/wader-core/networks.db ]; then + rm -f /usr/share/wader-core/networks.db + fi + ;; +esac + +#DEBHELPER# diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..326a02b --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,56 @@ +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html htmlhelp latex changes coverage + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview over all changed/added/deprecated items" + @echo " coverage to check documentation coverage for library and C API" + +clean: + -rm -rf _build/* + +html: + mkdir -p _build/html _build/doctrees + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html + @echo + @echo "Build finished. The HTML pages are in _build/html." + +latex: + mkdir -p _build/latex _build/doctrees + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex + @echo + @echo "Build finished; the LaTeX files are in _build/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +htmlhelp: + mkdir -p _build/htmlhelp _build/doctrees + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in _build/htmlhelp." + +changes: + mkdir -p _build/changes _build/doctrees + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes + @echo + @echo "The overview file is in _build/changes." + +coverage: + mkdir -p _build/coverage _build/doctrees + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) _build/coverage + @echo + @echo "Coverage finished; see _build/coverage/python.txt" + diff --git a/doc/_static/wader.css b/doc/_static/wader.css new file mode 100644 index 0000000..0d876d0 --- /dev/null +++ b/doc/_static/wader.css @@ -0,0 +1 @@ +@import url(foo.css); diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..ce6f7d6 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# Wader documentation build configuration file, created by +# sphinx-quickstart on Mon Apr 20 09:16:21 2009. + +from wader.common.consts import APP_VERSION, APP_NAME + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.coverage'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = [] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'contents' + +# General substitutions. +project = APP_NAME +copyright = 'The Wader project and contributors' + +# The default replacements for |version| and |release|, also used in various +# other places throughout the built documents. +# +# The short X.Y version. +version = APP_VERSION +release = version + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +today_fmt = '%d %B, %Y' + +# List of documents that shouldn't be included in the build. +#unused_docs = [] + +# If true, '()' will be appended to :func: etc. cross-reference text. +add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +add_module_names = False + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'trac' + +# Sphinx will recurse into subversion configuration folders and try to read +# any document file within. These should be ignored. +# Note: exclude_dirnames is new in Sphinx 0.5 +exclude_dirnames = ['.svn', '.git'] + +# Options for HTML output +# ----------------------- + +# The style sheet to use for HTML and HTML Help pages. A file of that name +# must exist either in Sphinx' static/ path, or in one of the custom paths +# given in html_static_path. +#html_style = 'wader.css' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +#html_static_path = ["_static"] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +html_use_smartypants = True + +# Content template for the index page. +#html_index = '' + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +html_additional_pages = {} + +# If false, no module index is generated. +html_use_modindex = True + +# If true, the reST sources are included in the HTML build as _sources/. +html_copy_source = True + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Waderdoc' + +html_show_sourcelink = True + +# Options for LaTeX output +# ------------------------ + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, document class [howto/manual]). +#latex_documents = [] +latex_documents = [ + ('contents', 'wader.tex', 'Wader Documentation', 'The Wader project', 'manual'), +] + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_use_modindex = True + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# If this isn't set to True, the LaTex writer can only handle six levels of headers. +latex_use_parts = True + diff --git a/doc/contents.rst b/doc/contents.rst new file mode 100644 index 0000000..a0de54b --- /dev/null +++ b/doc/contents.rst @@ -0,0 +1,31 @@ +.. _contents: + +============================= +Wader documentation contents +============================= + +.. toctree:: + :maxdepth: 2 + + user/index + devel/index + glossary + +Indices, glossary and tables +============================ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`glossary` +* :ref:`search` + + +.. toctree:: + :hidden: + :glob: + + modules/* + modules/dialers/* + modules/hardware/* + modules/oses/* + diff --git a/doc/devel/add-new-device.rst b/doc/devel/add-new-device.rst new file mode 100644 index 0000000..a91d06d --- /dev/null +++ b/doc/devel/add-new-device.rst @@ -0,0 +1,151 @@ +=================================== +How to add support for a new device +=================================== + +How to support a new device +=========================== + +All devices should inherit from :class:`~wader.common.middleware.WCDMAWrapper` +in order to override its base methods. It accepts two argument for +its constructor, an instance or subclass of +:class:`~wader.common.plugin.DevicePlugin` and the udi of the device to use. + +A DevicePlugin contains all the necessary information to speak with +the device: a data port, a control port (if exists), a baudrate, etc. +It also has the following attributes and members that you can +customise for your device plugin: + +- ``custom``: An instance or subclass of + :class:`wader.common.hardware.base.WCDMACustomizer`. See bellow. +- ``sim_klass``: An instance or subclass of + :class:`wader.common.sim.SIMBaseClass`. +- ``baudrate``: At what speed are we going to talk with this device + (default 115200). +- ``__remote_name__``: As some devices share the same vendor and product ids, + we will issue an `AT+GMR` command right at the beginning to find out the + real device model. Set this attribute to whatever your device replies to the + `AT+GMR` command. +- ``mapping``: A dictionary that, when is not empty, means that that + particular combination of vendor and product ids is shared between several + models from the same company. As the ids are the same, the only way to + differentiate them is issuing an `AT+GMR` command to get the device model. + This dict must have an entry for each model and a `default` entry that + will be used in cases where we do not know about the card model. For more + information about the mapping dictionary, have a look at the module + :class:`wader.common.plugins.huawei_exxx`. + +The object :class:`wader.common.hardware.base.WCDMACustomizer` acts as a +container for all the device-specific customizations such as: + +- ``wrapper_klass``: Specifies the class that will be used to wrap the + `Device` object. Used in situations where the default commands supplied + by :class:`wader.common.middleware.WCDMAWrapper` are not enough: + (i.e. devices with "interesting" firmwares). Some of its commands may + need an special parsing or an specific workaround. +- ``exporter_klass``: Specifies the class that will export the wrapper + methods over :term:`DBus`. +- State Machines: Each device its a world on its own, and even though they + are supposed to support the relevant GSM and 3GPP standards, some devices + prefer to differ from them. The `custom` object contains references to + the state machines that the device should use, (on situations where it + applies, such as with WCDMA devices of course): + + - ``auth_klass``: The state machine used to authenticate against the device, + default is :class:`wader.common.statem.auth.AuthStateMachine`. + - ``netr_klass``: The state machine used to register on the network, default + is :class:`wader.common.statem.networkreg.NetworkRegistrationStateMachine`. + - ``simp_klass``: The *simple* state machine specified by + :term:`ModemManager` v0.2. This state machine basically comprises the + previous two on a single (and simpler) one. + +- ``async_regexp``: regular expression object that will match whatever pattern + of unsolicited notifications the given device sends us. +- ``signal_translations``: Dictionary of tuples, each tuple has two members: + the first is the signal id and the second is a function that will translate + the signal arguments and the signal to the internal representation that Wader + uses. You can find some sample code in the + :class:`~wader.common.hardware.huawei` module. +- ``band_dict``: Dictionary with the AT strings necessary to switch the band + technology that the device uses. The number of possible bands is limited by + the device itself and the modifications that the manufacturer might have done + to the device's firmware. The reference band dictionary is in the + :class:`~wader.common.hardware` module. +- ``conn_dict``: Dictionary with 5 items, each one defines the AT string that + must be sent to the device in order to configure the connection mode + preferences (Gprs only, 3G preferred, any, etc.) This dictionaries can be + shared most of the time between different models from the same manufacturer. +- ``cmd_dict``: Dictionary with information about how each command should be + processed. ``cmd_dict`` most of the time will be a shallow copy of the + :class:`~wader.common.command` dict with minor modifications about how a + particular command is processed on the given device. +- ``device_capabilities``: List with all the unsolicited notifications that + this device will send us. If the device sends us every RSSI change that + detects, we don't need to poll manually the device for that information. + + +Overview of a simple DevicePlugin +================================= + +Lets have a look at the NovatelXU870 plugin: + +.. literalinclude:: ../../plugins/devices/novatel_xu870.py + :lines: 18- + +In an ideal world, devices have a unique vendor and product id tuple, they +conform to the relevant CDMA or WCDMA specs, and that's it. The device is +identified by its vendor and product ids and double-checked with its +`__remote_name__` attribute (the response to an `AT+GMR` command). +This vendor and product id tuple will usually use the `usb` bus, however +some devices might end up attached to the ``pci`` or `pcmcia` buses. The +last line in the plugin will create an instance of the plugin in wader's +plugin system -otherwise it will not be found!. + +Overview of a *relatively* simple DevicePlugin +============================================== + +Take for example the HuaweiE620 class: + +.. literalinclude:: ../../plugins/devices/huawei_e620.py + :lines: 19- + +The E620 plugin is identical to the XU870 except one small difference +regarding the parsing of the `get_roaming_ids` command. The E620 omits +some information that other devices do output, and the regular expression +object that parses it has to be updated. We get a new copy of the +`cmd_dict` dictionary attribute and modify it with the new regexp the +`get_roaming_ids` entry. The new `cmd_dict` is specified in its +Customizer object. + +Overview of a not so simple DevicePlugin +======================================== + +.. literalinclude:: ../../plugins/devices/huawei_e220.py + :lines: 19- + +Huawei's E220, despite sharing its manufacturer with the E620, has a couple +of minor differences that deserve some explanation. There's a bug in its +firmware that will reset the device if you ask its SMSC. The workaround is +to get once the SMSC before switching to UCS2, you'd be amazed of how long +took me to discover the fix. The second difference with the E620 is that +the E220 can have several product_ids, thus its product_id list has two +elements. + +Overview of a complex DevicePlugin +================================== + +.. literalinclude:: ../../plugins/devices/option_colt.py + :lines: 24- + +This data card is the buggiest card we've found so far, and has proven to be +an excellent challenge for the extensibility and granularity of our plugin +system. Basically we've found the following bugs on the card's firmware: + +- If PIN authentication is disabled and you issue an `AT+CPIN?`, the card + will reply with a `+CPIN: SIM PUK2`. +- Don't ask me why, but `AT+CPBR=1,250` does not work once the application + is running. I have tried replacing the command with an equivalent one + (`AT+CPBF=""`) without luck. + +So we had to modify the AuthStateMachine for this particular device and its +`cmd_dict`. + diff --git a/doc/devel/add-new-os.rst b/doc/devel/add-new-os.rst new file mode 100644 index 0000000..16141b9 --- /dev/null +++ b/doc/devel/add-new-os.rst @@ -0,0 +1,40 @@ +How to add support for a new distro/OS +====================================== + +How to add support for a new distro ++++++++++++++++++++++++++++++++++++ + +Adding support for a new distro is relatively straightforward, it basically +boils down to: + +- Inheriting from :class:`~wader.common.oses.linux.LinuxPlugin`. +- Implementing the ``is_valid`` method. This will return True if we are in + the given OS/Distro and False otherwise. Usually you will just need to + check for a well known file that your distro or OS ships with. +- Implement the ``get_timezone`` method. This method returns a string with + the timezone name (i.e. "Europe/Madrid"). Implementing this method is not + strictly necessary, and Wader can start up without it, but your SMS dates + will probably be off by some hours. + +Lets have a look at the Fedora plugin: + +.. literalinclude:: ../../plugins/oses/fedora.py + :lines: 21- + +As we can see, the Fedora plugin just defines ``is_valid`` and provides an +implementation for ``get_timezone``. + +How to add support for a new OS ++++++++++++++++++++++++++++++++ + +Adding support for a new OS is not as easy as the previous point. You need to +add a new os class to ``wader.common.oses`` with a working +implementation for the following methods/objects: + +- ``get_iface_stats``: Accepts just one parameter, the iface name, and + returns a tuple with tx,rx bytes. +- ``is_valid``: Returns True if the plugin is valid in the context where is + being run, otherwise returns False. +- ``hw_manager``: A instance of a class that implements the + :class:`~wader.common.interfaces.IHardwareManager` interface. + diff --git a/doc/devel/images/network_registration.png b/doc/devel/images/network_registration.png new file mode 100644 index 0000000000000000000000000000000000000000..6a3d2a65accdad5a4c0054539b7c7e44d2057587 GIT binary patch literal 21356 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

_9)gwWd=k$G%+4}^ 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 B#bW>f literal 0 HcmV?d00001 diff --git a/doc/devel/images/overview.png b/doc/devel/images/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..6735959dc78b6fa97493f61bad45d090a3c92de4 GIT binary patch literal 27955 zcmbrm1zc2J|1UZQqDY8Hmx6RjHz*QHN=SEi_fQ7i-5??*-QC?K-8po_(A+gX_dWN0 z?>V3U{oix+k;fS}v-e(mt>5okOaD*OVrVD?C=dt)?c)bwISAyw8w7GM{P833&fS7# zEco9eT?sK^$Q|PMkNT`g@XizK4=Q%xCngYo?g>#RI)gWn?LSJ1AWuBJkHN_}vwgn? z0(lAfDEv;rd3tBg$ypkH*Les_e#P-5!VeYN?iVI5)MVmIzYk>o`1*IZ2iqq5k9I3FM72VJUl#24)I1^4ZXsBOz;ay0P$6J5$o@Z2C5Gd5_(6g-G2(T zQh$mBE^lx5ijlet>+8RUfCJ7iF2ZAC3^^8XwcMIdHb?Jqxg5PlBVZdM!3N*GAkKx= z-^0hpXEUD+`}OO!4+g2U|8t5ew+oiu7zVxT^Fzr5HvhIZ;R=hHj{SWmZilTG=;-K> z*&2sHVqQn%&;Z1R8Go@l+nGw@aR{)34TLkOaX^xik`B(!C~0UuLO%!#3$I$a?q%6? zT1>a8S=AeSuU{xBE*>U%j<|rt6ez|*dmwI(LQVv;+twc%!-1qHUV(v6Z!S+qHUAz$ z2o6d8_U*xwCr>)8Lx{MkHEP(L_GWdKTD&1~l9Z5$$jJV6DRJ?Z79mc8K*R+NeJQkE z>-p~EL(p1KP*8WZSNkVNz7r3&vxC}{!Vm3RpR09Z@mc5|)AP0b+>IE6D5ibX^uDI|1%b90kkqb4dln|f z;LJapsK8e;6gR}z|9EkPs+{wXsbb$#Sw?*p4O>W9So4X6D*J0s4>fF|FESoxiU*-d zd=BrtpJ9e8ebtg;LJ~j{a7i-f;8^WT&_KK!D{ZPuz_~ZC`0B;^YG_$5anlewzrtfM zN}EYl_9>U&YI2?phFeZQ0|WQ3Dfl1r@qd27oW8$=72e3e&JgVvXGYt`8q-Qihjfhn z>6NfBxUpN8iRa6BC%eLKJ+9e!gG2rjOAq0MnL>Vk_f!4&NCgb|Hzt05LpelsNM#Wc zc^2L%JaOO;hK2g?SHwa8jTisV!Cj-)$9qy0#M5V8CQIQ%KrS_G}n#69Op6j&L_sYRHb%*LqR{amrQSIL)d5 z{+w+XBQB4Qyilrh&I>LF=F$c8{RiCoE7Zw#E zEN+UzUAo5RGdeojZ5WB>h1`w7Wp{xZ;uyj;$!%=p*F`76??!es>wvz|hoR=N=rQ81 zH@g!y#a1vLci-h{k?Vvh!HI>oq*}chJ5u6{)!o5`OyTY4*G4CvU0q!-Zx#8@?wxhm zy2|VXOR(aOjEq7ycrxi0@-82F!KvzHywS1o-dI4`UH@qB)l^So{+&oid4CSF-`(uoUEHGPZCA<$Mo=Om0u=V`mIMJrAwp*p-5lBEH#4U20u?^tD*69o#tv#i<)i3P&KHn;o!&0;yL9;Cw`Mn6aD^(&%8pyFV4Z* z$@n4q&YZi*J9nax#b}p^hi%I%!`mR!k#ldhsb?ghmpRryDEKukV?fR4ExEzm`PL?- z3!TLoqKbH)&s(3e#ECg}7xYnETM0!%26)A%S`6^{?B2Ai;y_oW$jQhGQRd2Ckug0q zf*P4B74(1Kr+QYx`+ay$Ot2(b>+`we=Vn!cc-5_K6Zcy#ndv-VpO{Ik$p9`SJQNhS_XFUk-2$`xdg zOm)o6C1#E#j#PQ-@jS9`tM+2m$!a~IfxY*#f>f8le%j{rn4{11MPN!46%{pA)-m-~ zGQb)i+&yjj!iub1W`bGHXBHk6(KbD2wz*Ky=;-x#N%5Ik)!jZyb-GQOjy6jSW?E77 zaUy8cLuDeajzT<-t4Yg{WI-P&rjsV!RRZ%(WJu~W8-|v1o*04r%f;Qj;KX!7hzJT! zGfg8FI3bl*id)d0z%( zSLcan6JQdeg%WfWX;|_K7$1K@VI;dFs4;nuD1!f#Lc}BeZ@l>L9lUy&2Rf?@0eT8= zH>X)FC~%)_#RCFfA}Z{W8`t!@k@d`7_On6d8`=iiuZU6VI@Yl;@UB&31|@bCDZ}jV z)o9vmEn#7XQ}MVS(wm)GD!oc6R>jt8%`!*A76Qd|t=waQQCeM~P>4@2-*mh)!z&%- zmCV?t1g?z}osW?}3vCCh?cNpzdJ=C=6BUZvQ3P_sx7v;IY^3q=aqztoQyJ=T8)ong z$;p}N%}G}u%LR$m)r>&e@6-p~y@lD4tQ_Mi3Vuev-jb+czY`WF`N(PU->HrNUP7;z z2kLp%8(oOK%1+oAMYQB(!GN!d*zq26yf;MCxxKtFTmc0OpWEp*7Ou>-L@!z>YA1Qu zicEQOgB#CH*6GB+Gnb$?WPj8Y=D4l7T=}}sHlRv1E%yg@cXges_14?KL^I*HcczYA z=JQua{19EMHo0FSn7eO4H*vvyurRckc9J|2JL6v&dm5ZMkKVX#r1mr z-p#MA^sB|sN`7?|R|K0Hj$FYFVXf#?7RvG&sWq_bU?P%f+5O&FzLQ-=Es$^+9zcJD zd}7S*V!)O+9Y$Jl{Wg|T0}@wj$=GkorU&YZ;Vya#ch@`?v^$kkFxZ_ilHbfxPgURi z_4Zjox^~0YD()HsRWWCME;S4bibN@nYxnEo{?m=l`1W}}1G`DXWvfp4j(pT|x`_^b z789!O#*vMS^zLYWT)NIYws~a6Ge))(OXIR?9M9mxr=4~*m(TY^X2FUR1m*vs_H2vI zpX1-10lRcnM0W8Zx5PP1vMWnvNGnw?FiLky_&x0|-uIJEwxpm?z2xUl9;;Oxt#@(y z9bMQl(c5U)xkNmAfoG9V4N~ z{M%kthW=@{@)J7E{X-tm;MM}GwLYyS)6UjFpL&rmiDeI^-OqI_Ame%+x3^N8*w1U$ zPygGgPOFnESD?%?L(9@X3gdW2_w@D;~M zgU*#CsVhALeQ{aQ>%(_sn)}X}_=F#|zrA%NntC@k?+`p%zhH45g5JujZD1k-szcdD z9$Ib4-u)k{-lqEH-Qumk(M*)fU#XPp8!U9YyS-U^;4ynfZn0I-JT)IbAL+_!F~ye9 zFdvmPWh+-|Bo(5?b?ZBGdr_Insyp!5^mBo?m5bI_(TBwz`_rb+N_o%k(nV24(9(2>C8)hW5y>7EU>rrteo)dY$@3Y#I z(i`Uiztwm&h}Sm~E@84d=NA-&%E#-%vg!NLZT`Z}2MHtHz4c6Tj}_kwBg>ZyNIzN+(1WztQvB=t3o+Ge9B8C+ISqvNpG?z`HMM64a?S7xI$r*i z3WOkfGQ=arW7R4ub?95#p8|D4FVXqn^It1e5F%dBps=}D3b~)q7LFP~KOD=eeAcAT zQmo(hd~rz%pH-<(e{@yq{PR(|#*JfAt!>DY1(}!5)(3i(+j}>)K^K;&wT63$R?^gJ z_NYd>k)k&bcXhuV{vHeJ;vRqRBr90-}ka%p9h`Y*{-Pp+(7)mUC=GY zevqs0Q(0Ut$6MvhU1QC3Zp2cQ-skC4XO66e@0-H98WX-HKRB#hCgr_3$r$apdj8UC zpw2n!;3Xf+EFKJYv3;^U(9Z3CaYE_=xo*G6g%u-uU{c}K(SR2JU7NvoF`vkY7$F!F zk9>o>%j`@wtNVrh&*h&%9AiC7tSux>2L-Jcj;EDX4Vyhpg~(eWS}r=-zA~CL?{42I zY^r;MS+uOw9SY!x=Z(Fd&O2ApnkkJh;1-mXmGSPqVPNPtpSEiK6`g^-OIXo4-7}4M z*UK*2_<1^?4L3$}u`^$=gw39YwEx`iSYkl%%vCU4DcOid27nCV3CaTZw{*%yq;c+QjCahyi;RF+Nv zy)o;M>8p3?L8(2VXm8%0qKt8~z#k(I66tpY;Ge^fVQpWrcpk4-%VVV0$*B{o;}2Ov z^q?XA_Bty^gY>i!>n|867Pyu2|Z2wJO~SG~ilu(!I;MndUw!JO!=($rN!*nZam~wWHvs zz&9h*^^Rmhwj)bnP$62ReeV@nj|xRZL^{_mZ+lkl=z0g(z~+;w&R*6`)_!VGb}=kS z`UvI@ZlQ+$X~7u42ZAhTEI$#kj+V!%+-!F|4tJ+B!P#l4%{v z^j5WUv*l$gq-I0`{KNy$5a43rb`i|UmtjOIc8jtt--`J=j$GjrHbr)ej>{J7?mQTs zZXupk=7eU1kIG4fPxr>EK>FJio-5A-B@^Rca9JwWNHw-f9<8?189yt0Fn+$+p$_liv;NE3>FfS*9iOlcK zcU0La4HVz~@i>C@hiXv>mRuVq>OR+Vb`-?aSCX%5}?vwufiesfzM-VRQlp zv|GXCGGiEOoQung=L#YDS0CZk9>+Y=7s)J0X@ANTey*h3^z7@an;w>FfR9Kaa$!jh zxFN5^va&pH+%fo3zXZ(_JcVJSDEdXM_jfrwjMS4 zl(SO9G}W#0JmUl$yCeoXrq{!DFq$}G^HnAvP%p`Sea=WhGBw{k@MYXVm9VuD4GIal zATm)*SIN8pYbAIBbnRcaW~b$P!X&Q%8YqPaP6@u2$fcU^zhb*)dbLw&3+s~FBdWer zm~nGCG7;;2D)xUwSpQ3Q%a;-@{*sJLCW6z5(Xddfc{!g@A;@U;o$d2-TGyOsKpbG= z^sRG5J$EFdlmsC1x#u?J+SAxiuU2z%wc2AZKf8T?47gZ0)m63>E&?>Jau2ir)fN61 z>cW3W4gc}tKMrO>=2zXNuvt=lP3PqM`x#J5M*M>UWibH;9-OpwCRIbYKf((M5x`xDLILo{l9{$At1 zvyD}6gHtl|wh&_8I6fDa&QMY+=-JNs`T0sAaQzJAO zqy{4sAilXBG$Xqmw&Mno@F!fs_e*Wp#3`kdx>ma*{r&x$M@J(&f(c8to9{y!F1P6T z`1rW&H=ZG(ym(1L;cYQfQEb>xV7uP;@xzD50s;cUA|l~TS^^MsQh}h_T3!aV3bt_| zNLdo!19yMa11)gcoCo?J&>C6n)+JTTjCD8|xVhu$RZ503BybtE8v4M=`ZJLqK3v?{ z>FbMU^$!kiZ*KmA(715uAA9-wB5Tw-W{8E7K>ieHBvMKx3{_fb?K*(FxlnQ`m^dKj zviAD*>zAYs#!HD9dWiXKRX92^kAA{@+I!^jd^LF28YLyA?dAx@O4L4+uAUyZ`(@c7 z7BO)?`CG&ja~V91+5?iJjjgTg&dJG%eSv0uKwzLmrU@e>qp+xGL_~yc(Dr;iFTlv) z=7)cZJWXIT(dCe_wtmwa%f#uUR%zKkl=j}?1%f@@UnpH(Tzu>1cCDACrmU=OWCZWx zI6OR@oSM?Hu&@Y%t^{)yRaC4ue?tZ*$E*L|h{t9eCcO!i(XPe;JN^u{G$AC*xtjL0 z_r4@EnQ|H8=F?>~qvukI9I_eW*rhgTgdDHmya^-qybjM*D%?U>2jdV9BR*Hx&~Smz zIc?5Xv*I$Uzv=Mx_C6d_(z^G?cJ;%0U%Z|ptz&xtc3%Q}AXCHna(Hil3e6;5_*5(n9XKc7~J01tSl-plP70pMhi4}#bfB7c*Vxzf)i$HHF{K4RV^+r z3pIM&D3_Z?Pr-L?G)FU~Y#bd8xBldGRAC65Ln6ENudAx7Hy?vZ7p^pKccY2}t`pPc z3@|IiU;a~+{+~s_|5%FuesFl~H$({=?Ac_+eWFsW;T`5F?DJ&aR3(fl5EIkpGgxlmzgrXUqnVXGMM>{4BTM*ZEA7`Vy$yoD7x3?= z`L_$~ehsuSo66;sOWR2Ki0bE1@3ND!teV+;-wNBykxj`J`B6E1PG*;j1p;2C6i1$I<}4Za#sjBFGLyWWg@CCn5QotkJ}ac+q(TC4STak zYtb`JDbI)&^n!S?Z2}Vb%_0hA1x)Le0{Kpl4hFKFs^E+B@>6>`5?LkO=B3PT6lW;G zSDYOcUl_{gb4BJKxtN@q&=oAy-G=_LS!oE4`yGF7I~*%x?zFwfz!SW4qA-3j92ClB zbNC%y_phYZS(|E~3i|jFfL;Eo*zIG{N>kkKRF)x@p~+e1OC^0z;`LCqLaO_w=#07} zSwoo_c?1ad3+53NG#HKZ@!hMWxG=mOi`{A)Scm)_|8vsU%QOA4El5wX2%k%wV=TSS zlWEEg!d22OxF#=0)x?qnOso$*{w8#{5zx5jkLDQ2ALPWJ16s_{f$hW? zlKX925BB5;Lw70DyMtyL0c<8;K6^S^=(#RBK`#7sb6xGc`;*x-R#qd9m?iPjeJgT7P;kOZ69$VEls^^xY-6$~ zRQRO+!{7(bX&DV3?AlpJ$xT*obB-xUQE^3@=gh5h%+bTe59gSsXL3M z5GXFpaNykWcx^HM8w53;{kuG77DAo{C+SmVed?tL3mrcA=EKEd(^eXpHFZ=IyeoQ; zpbeyyw;p%b!kuE_4pDTfA*axZ2MBrk zD*;k``4e7ZQT{?#8FgvEsim2C`g6RqsCN|;LdbatMq3JfSl!O7UCVdcGf0_$wk5TK zK{%Qx${NzgFO+uQTe;q@eAoj(`ak({xK9Jc?sDqkizMJ-9MZsA!3S-O{CX;d%*HNc zC~W`m{Wsqu=@q9*_^!Z!2Dj14pNxzwo7dNmOWzxo0@`Va8rOUkInT-axlViK{%@u1 z5TF=ZY*$iny`GHYfbN>(n5#Fnq&-z(vk%AY^N>w*MyhbH56-14WLZsqRd!oeK5T{y zC656_^+%K$4ML4&7*u83TZjV+I)d~@*)p{M!N@FuC)V4a?0;z4eQ0|6TV!-gio5ch zh`cuVSHF=(&&Atm4C9Tn|Fz5UzlQXhl=A7*kbXhY+DE&}RW`3O78s>U z-}P6y-fUsPw*OH_rrgarFKl7yWojm+o5N3rR|g~S{I}5#&|U)B8coouX{l_~fZK3F_MT zoEqxgpfz|y48A@`qnM2+Q9;i<&6YF%y=a*(Ig0M(e&3&7QE)e2ew z*3HjdS_{({^*M+M93Vf^J;fUCHi1qthpauEDe{ox@h?~JEig7Bp`6e-hDyh^X=OFl z(j^$$Y?a+NfK+WZc3$bZHoGBh&eSj=u5Q|Z8lZLxAjd1lD1xgFYv*|i)Rp#;mIxp+ z*)T3j9w+oyCp#0u$@Tyno63tVG&#*=%b$+|j0dm2knHGWLxX~h>DMf`?P#tDlWX~g zNL%^i1kXQ7fLO8Q^%ivwAm-Smpxren3k?YT=~jG%F#jYev9JS_-elr3D#vwAb`~z@rR`pgek{7*u3AnTZ zz2em9Jsc0Fu!W&^38xn1E z0qMuM$-y>L<|W~DP)5DU+@M%=*KnhYcd8W6!f`d0zIs17K-1oC&X+Ma(z9q)^RU7M z_Q1G^GoDvi+5@L7^=As=O-XJ81P0OfG` zpt@CGUmvfR+ih^OI)g3D&VO>5LBU1V>r z(>!#bm&D=w;L-GL6`3u2+6%ndQ9;TF9Wn;8gfoP5e;%O?zAZtiMPE zFkO~*xJtfBU2zWAiP|OYCSP2%4L&Sc>qUcic@xv`ggg7qWOgc04Ox*I(tml2TQ0SvUril!RBMK0`nO zHOz}^25d8ElbFC!dwffUZDYnOd`?tuwwen!osnhf>d6VTmK34N0xoxN^wChIgBH$B znP1oBhv`2S$CX>LNfCWtgP$t1=sp2X2>y!Rb6so8^}cp9+$TW3GFosG4Gv&0sw_9U zJlYhk1#>!#T|$wb?@`jfvH*@GQ~p3o740XD-n@FSV%g4YdP~i7>9pD_qNN)@OoCdX?{FKlL30TG(&nnOwh`sZmvG~EG2`l zxUwAiWuOBNn5)d0p9?=FgLU@+Lx$W3$(Nbl84t_|3efD8#FTiD>|20aRDfP^;#Nw< zAYl0g_$-f{@Fc}e1BS0wWm{U6G2Wi#O-;Sgd9W|znhI9CiDoJHg9pe2{Ps9FIOZ3o zPxTrITeBA2dZOd#Ui4{&aX8F;%CN@5*_0$=nFsqSAQ* zQ0qE!WPs0Ss`%SvoK-Q?8eg4Aoy%?S4X9p!PO?oDm5_OnYZ`{5Q)R9tL7OUQ*IK#x zxG&#S0U@op8LFyo1CM{Pxmi-3FC5cOY^cD47ZeqPv)aZrI1cCj>@Oe@BDANQw$n-F z8OVW0E*zoZA++GH*WZkl8i{KScb35e`06R>Nl#CIK)uwQ?IgxuQcxJNnA2;#q@iWC zbqBv`COu5ZZ-Vp1v3PLfz9Kt{%2&n~Kl0aC9W(!-TPFk@hQaUy^1eIs+o$Ck3+BqA zhpLQ!j-b2VQ<-1(rF11{1bB=+aysFWc&s9)&Ls9|2#>mpPCCn81hC$b8cQ<*C9^!C zzkXX+ZcqEXh{!XS?v1E|e+~n-FB#cg5x^EnL0nJX?(J05EGx9_&@gyTL{Z3Rf2z9= zgj5CdzmVF6gu~Y&vv-5hIr?xJXkkf>t|Vf-VxkVDh>mx6c0{g6ZsTD6IZXtCmGPfA zS=y|*mZ7{Fob9i5()_*aU}vwl{U^VvHLI*oOE49Ov{HMf%;G%in0eYjU;i04uJ^a( z)>MyygUJ*<)?kT`Qm=qT3YsNhD9UxYBj>%YMAky|*N8cKCsq7&fav<-^}>!?rfyeh zEVg^pmoT&?!SM1G%nBDu>M4a`}D*1lel z+#9mV8)SfhM5RgtCDvU+323IGeUwC zZ0{;CevWU2p|1dx3%+Ztl1l;l_PWp?qyQdl_Le08Bn(y8>rRgmh7p)OEdcz|w&;MZ6~J;iBp_JMh(5 z^e#~KT-W5HgJI~ER+A|Lkyi&QS%c@6txje66XQEmJ&k@J{nCo9)|q-jYDm1jyT2Nw zt;eeW$y1Q$Md&W8gS^fgFOCiKaYyw8$IobV!V{I^Z>&1Xsk?@!EOvB%ATeh zm}l|F-AQ5=D-o>t}HG!lds0803nPL&x=+X&h>TR+A)eg!na4+_!X7~ z@axfpM#FQdjQa-0n)ww0U{=od=lX?&KuzvzE#C-NkWBZ~Bf}quqQbUG8!45})KT}| z;CTTt^o{P`W8m#!YAkF71ec>Fo>{dNMvwxUqpYGjT;ZWs6S67Ze2s+A8ObLQ;Nx2G z1eTkjHC&;oM)s4u%Q7;V#k+Ti`v>cT29l;BC!b}t`B>m1VqxQo1gbLFzoh}`l53Z# zlF2Y$s5G`vN_C!!xn_ceLy_GNKY2`_J_230!=r;c)GU#F90U#llxd1x4>$E1j&<#N z8QxDx2x=M-x%KI@B0Z_lllg_k?m`TEnUu+*!*@|fdfj*47f9hm|5;y~XdIzvn z59&iQ&k}pGUDEN`+@Ar0O1&u;G0ys0JqI8gNwNJF+q}Y{9!=vNO#IOxkKcH7ZORYob0l&(pB=)UQThbj-1K}aPA$nXNl?E`DuQ_Gx3zy8>JA?AcT@qM zlRZ^Rc|ow2J`OGKl4tts^#DtWKYBN%#-28@m4mQVNw;taq#Dc#C*`*5RaY&-Vii*> zmM{Cane^v4eJu=+Fe3XqqyXIkaGHnu?iC^2Vil}dgv~CzI)-o=_&edN#{;QY<+K5S zIF#63N&|32!Yc!E8}V2yop=qvYX$Wsg35?QPu<>)ZC;~32lhFXkkInk?RzffSIod- zw_6zq&bo!Z7WmLsnaJe*6o-=Ib<6>2%SYu@FC~1Yf;M%RS7x4I$vKkFSa4XjZ(zqa zlqub&h$Q03R%lF#NfTCDt7p0&K!IIgPdxhB`voYIE-u7;c45G1FjcxeeVQ1^i`zNY zd|G_`@B+CQ_#i7UcCSy5K@`Hzdvik2jvN-!27()@_SX;TdVRItCy<+=i`Mf72qB?_ z1I9U%+9XHfQ1Sad&FYW$N!LHm9_S z@0!1iei80wF(wCqo}YOVFT{~(4gt+_sL{fcPr~KCH6Uv2(D62brj(v!x>A!nO1)bE zp5ExSqXOVgG_hU)-1!NvdUr6$fUM?CSEGc!#GrM^YfCD(>oY9@ln&UO%ad*49XEuD zH;h?K$(c5H9bD`VE!FX?;C@!my;o3ES(AWTu#CC-1vFyFrSxX+$WTT{T_ldoPlZG| zaPUtAN3#%j3o|yCZ~qYhh_`?B_WG9BEonY+V@$>Av_Nq`usKv3EUE)2z;r%3I--Gn zvtU#+J;`ncO_Iy=^qO>F1yG3UpKFLT5X5expCVf}OeL{sR0pUX@U}HTiB?a3jwY!( z2aH%|Y;yffXJ)uI|NQiA!_Fy%QKO*5h3=Zkg^dJQ^9UE-VWCh0 zIM9=VW`Z!B)bw{_kE(EOj6N;C+;SZ=*yn_*2wdv?sKRpyKUX`1TvzRiiF$G-d~~NQ z1`HU?-q*(Gtvp#-S$5#j+KmtBOm!JZ?h7VsV!851n!V97K$wvI$RwIL)?m5wcffy` z*OpXO+QC-hNd>PDm}{O^KDSzer;vK=UtnV6A~pR^&OLw*hDKP3kC^CFJ-4KSSa0vO zR?MJVteox@%ENDJ^5)B@&FdRe;j!+wy>(zo%L9*Xp5QvhF#^Q(y5!GW^>vzyFFK7U zeh@$9Wc+n9lVQSH# zK(N*-o~jbiN=HULcgvya9fSye3^4jL*OxDzK7INK3B3had;qfQCiadS6GlFJ4-8e3 z+6G{dBXGmg9lkVpV`ILKw;csruRDf9G?8*FVW>(~4}`Q32LJf3icv+-z1>HjS~4Df z?p16DX)8eNA=({B_+_~ZApo#fB|9ggqw!krv@nCHjP6{D!(XnFvO=x!;~C(BvH%eS zehOxhavXhE0tFq%czmk*Ap-rBt|x-#@ICh%9a!7n%c0{0OAu}C#kT>V9)J`5-uoPE z5g`ZzDiyHXAb9LnFI4hMivV2tE{&EEW5fXQaljWX*r|>?J|tRq<3k3~Hy}EF0N&%q zp&4_;C*cw6{Z9IpLj!k@9DpG$Ot?GDaU0+`W64nrft0Xh{RS9rq? z53v?fvy)GrkED+YuX;!EidiD~dLd4|Ulq=~Qr9`$OeSxOeG67syTqQ zh~>ind{8b$A9$F8`>}x9fpAW+4zQ>}PyUJZGgy9aE%3IcADFT66)LxN08)cA528DB zm#qINP||<^Vy2^0@&mw0(HZ|hs-t3Jl>)`d0G?P~ z)QfcOJyV^qNH%fZ?I~BOvPn}3C4MU^`Uo`LyxHOiFmsD+`^3Qj`-nXWmFz7lD`V5d z1qBB{)8k$&T2>Yoz>|uY>!qW4=ocEU!<`_YKQFIubPOMheH8!5(y`B59SYjQ&29p= z1Fbml{8ivp4OgAlK?*?}4mX+uGtaYY-} zzOzGzAi*&!B;9pX69GgV>p^e?;Ll)BaS;1t`5)9j z3`}#KW}PS+b)SaFD@67Hp96{q@+&~k=>VvLe_`iiV5r&w;5hg%xcm~LO?6EUiDdoJW!;Hs*>ZfwD6to(kOf1AMom>Y!GiE@e2am# zjrF}BDN6h}s47i$4#(NE)hzvw*jXF+xPh|{@_e?idE)y5s6PO&7ai397#ra>y(v%0 z6mLHJ@)HPVB%yHO>)4nN79(;}fV%$pF7f0mGIFBTsFmxx-m2(kz^su^$$w>K9lyNz zGBvnQ%meLutp6_fE4UvDP#J-*5g8NH0b+%sGa$=-un>K>2J*xmRUi_6onv95QcD2> z)9XzAbWF(d9ZWDXU__T-=L&rk2$#%FKvO_erA%Nimz|pqPFW5rfYEx#ndR9N^y8}c?IXsQJ%c3E#J)J)^L91ukC?hCPKCZ(jRjfVqvD!{WVI5%UAqTU0kenfg!Fh^MyH^AX@ zDDWunr}7Qg?JL^xbG-8o6FYLvCu0AtoJ+1tgmU%?rr2^)Q?URW!ad2$q(QCL}C1UMNGf{n&pi>AOJ)%LrFv752`OgD-egTXI?P@ z$PoLJDICnUNe)nRXQ9@Ec+yqLO}lL~(_$8~`LN8r&h!1_IveA@|g2_{cO~{81hUsa(+V zs6Zd|a(q7{3nI&Ut}C~}Pc*Sm-(KnL+E~G)>jh4`%O3pdtJBw1L*GtWfzfS4$CvgW&^k zS%Fiss5RXH^n7NAsXs=Vh#eU5HTJ*J{ggfd;tUeX%vTo{AQ(9zM|8Kx1$KH+c)0UT zS(LDv9R89deauK-|F-M=8N!S5n7+fJ!cAJ8#ejkQwiws|0|7~B=6Ff43Fp37vY6w2 zYY>hGhzMaIs79OJdjkwVVfw(u9k|id(HIWc%3^W$#pUD1a|Cg1D|wutU?xntwOe0SMiKWvR5t+Lw>QJHB8@kZpq4~l$kCWbZKZdN9MF&pS-hAA^U z4)*piXWzU8t%em`q2!ef0R(Kl0GyK#5we^vF%iWWI%}PYuQ8_uW)7RQh_HI#qV69N zY0)HofEEyrvGOet{^Kgl2?Q;#!dIj8OC1(~4cDHC3x@6W_^yVLqv?w5c6b&H7|^aE z``xf)K`PVFFO-xt4PYn90L1b;rj&U1vQX)VFuMV~0>l{#8R=t~qiZenSbO_H$e^G! zknlGz3!n+Ru3ddD?kbu{M zJ7YE2GHcS&@nPDAukU-$iN1Mtj(s(q>MMJ?vk zttN7h2huQsXO+(*jEr{+kCJNa1DaVM$?wjYzRq}k&l*IS4W?ssTHsh<2B9~bedPud z0zxW%Gi1*1;5s3@0qyy(G7+K}hyO$R8TMQU{%g(zSE=&6IcQ5fxx=B4PMItDuVFu zotdKyH5z617ztt5V4H~>tNAJt(j@o3@89s5iQh_i#rMAiu-&5~SV zi61AI@ddQf+-^!PT?`Fu%8&rNwty4?UH-T91{PRevkrDD?1p1{h3~w5vkU@X&?E8~ zh;EOd?GtuHz)kgzj>cvXwET#(O}}$v$kfS1l(pg)0#JUYO0eOH)lBMKrz0yYl3O&} z&1tq4p-}*j1)&%Hzr=keD}&&_!(IFhTAkz zjnDecI3}78m^gMjO%R_bkbec?D-oO0XTdMjAXe{h*jj^YUCH&9;(&ZNNUBFz_SYcf zkNLy{5+ENTDD~nJemJq1<1--1=BboCccKe1vzZ{N9RLA_58@w}Zxr|CbMz^8*+6JJ z@Ymb<`%w()F+jN}K3!Bab^ZhS;Uk8omjqHmfcD+%0-=EXtuhwZi}k$Z)|u0JO0$?e zVC3gEqS-XM-#~$AEvd@q&-tj~-ISw2e4~c|2rThcxP!zw0$1$m)gr`@Z_^konPZ;$ z4TqOan-37<1rkyaWi=Ifux-Y7fW&px79*8c5@dOESE10U97}92E7^>1%kz&LD z%)asNLkfE_iz%z1?csUu5JLK8%U*I=Gj#i?Mu63BaJCiNaZx7-pN01o^V^$_({#FC zt(?hMIIGn0)%Uro{xJ(T;y?5MEtrCoG?a;pT8t(ys^iZ&nMYkLj~_`Xf|5%XDXGiT2(vF zh$X6@uRS(?@%D;xSEb71m7=2JubsHwyJ!SKDjW;tw&78+i-TMYhKSPNYro~1(jeO&%D(%h4OKX{8C7OB zc8T&W4IvzTBP1s z$Y&le)1}Dkrow@_nHnRe6oU`3uMAt9tmf-ys^N7b>)6wcV<}H=sEYVpxrb9l?qI!B zJ=1l;kCfd!hL(c^{b%vJW34>9Su;{}=C@q>RM@EokHF2W9CHc|9t+up(x1f9|0@d+ z94r;$y01Uqee9SL9E>(KHHCA`Qp!?XUd(=%Icqtpss>9+jgO88PY;Ie$k-Rg_#SV> z;PD}Bd6TPzTr2PTtJ76uB8-PP!SSX2<=lAKqSqpy+Wk&Aq0Ezs=Z4E3gU6bnJVy>t z>6qzB^o!qByML|~-zB^=d~sxs52(17cfLVhGT ztv$7D+4uGfN11ej;J#+-Z?&j~vq?5=F7;0wa>oo@H|!ldcGwDa`b{}YH;!S)c^LA( z(gq{fAGx@Bn9rh%X9wymZn=;*7Agd3l;o^!?eD+bXnzSJ5Ff$whJe?)12cAUY@W~Y zD_QX-OMXw`uMty8fFF)7yK$(AbJ|@CFbNXWn>lN^^kf+^AXtRan`=adPOp?)!^2BwBt9`oC^R(b}4w#s1nMToG@Ww zIV^UXLb1=tEEr3xNGnzu0SZkLaWaF4^8(h1ZkPx>qN2fqAx36Huvyntxl#vpo1;%x zb7Ea%bGj~9BqDAG22CPLcD^fzyMR9q+Q-c764z4fxpP60jVZsriXoxkP(PlncMr4S zZT)#Se{6$g`yb9^DEo5f-7vB#JE2k|xfwC^*~ z(;N?4xA$cWgt<0VA^;NYsiH+16Ppj`A2z|r#Q+f9O zq}f<#+IMkj@12nC*Xn$!RP&bluDMgu6TC0jiq(5}lMQ=cBG(SbtTpVKZeT@FNqKaQG!yMb=a*GN`bskO+ z4FWBlqv0&udqcn87o2`SMQv>!ctHeVsy!d}B+7v) zN88Wa`z~ZP$O0u@bZsH#2X<~nkw-VW04bj+vgEC8y?|Sy(tF;eeI>z#ol&)KS%>cm zKM^NE50zPhF$84!NRa3L**nsL_;)j80wi)`Kh&rEe(U!f#KglpO^$E-&(Y3Wt%^vK zMIDv{v9+xCMyh>FcEd|WkGd;UuMP2A-^7*c#>_we^&PR%I z5=d9QFm)^4Bt%SDbs?g(IiddWJd)!Gx7G>Y zjNcT6cojha+R9OEAb3E9@fx5CYq-W@L*a$((iRe}tHQ6ls~dblC%*33$a{XXnB4&M z-&3wJp;>{2WHl(ct8yAkfwVT{xOsj}JgT)ciYnwRgoxig%E_>?)lWwBQ@Q%*o@i>Z zlP|_NR_f+^Fi?QwaoI6AaXE?0sDe}xQW(j%Rf+EJ(im%UTND5T3~u}wHY7VcgIOI* zAMm2?usy4~qOD-o3Q60S0PM_^V?Je`o(Kwe?;WF4$m-sfvdz~6Mnc)Ch1vY_?qnXh zHM~3;!c^&jZzN_^dOa0r#LadW7C+=&hN085OoO`d2I2*(%*I58R)~>e0AhY%c6-++ z^QTbogH{gUMhdM#F2rf!!R=^Hs`Al3Mc#BVidWDKe$wUX3ovlkN$0Fmsb;2@SznyK z@sXa-16#c&Xs-qMyPL}k-eG62)}c0b$L8%u466G)?AvW&Iq$jLmfsl6_C7183jmdI zYmo-=D8dDQ8ZZ6)?$~(sY}rfnUYTbu|E@ks>>_V+TR!p+)E{2&(k>kDj`udNm;ZZOF{&R65PyxScp%@PQ=2l=-!@uLX zb*_SLEb@Q(^EW%hY^GVsoljU{sM{u-m!EHj-$PrKm=mV)By{@T&3F@z-;ooihK# z5RJ4K@COxb^w;>@7kuz6=7cRL~{)yW7`v&)xvZpk|-Re3T!aW1%KB_!_ zyzaUQ>(p2aU*{NEElEw-p_`6fXudi|Nu|bd(Qe=iWAjMoYwtjUqH>9hEj za&de5ge29ybFS8*k>`kH*1d5$Qka)gyE6Qfy(}ewj{V81#=CbS^(`r>$M+MLi(-7n z{qI%$f3NSK$8X={@=ZY|BHAWMGFya^2roJix?i2n)m4(__x5kz5tF&$DJR64Tvfb~ zc$8GSAe#9~y)byq#J$%(V;h7VXVzXR?n*w6uWZt%TzNRNvD7eL=5J5re+$|Lx0C@C17sOsi3fMwJEQvs!F@SX+o^}y0P*3jO)r4 z9R>4Av)1Igm`|TR{n5LxRP?Me1Mj%+;6eXMa{cOSd&Ng=k%k?8)e*V&LrlD?;h(;I z*;po|q^!IbGz74knWftx|d&Wi5ai6}3e!=x6=xls-HTw{{ z0N-1*KYc1{_Vm}faSEjr9fauFqk_N3`TO_pJAr{)c~wI7%6vEX%bYrOs=L2m0eM}{ zEA!-Bo4yBILhliSGoRV~R?9m%`5?OGK2=us3=G_li{reSWlp7saIp(1SHfze#Hiod z4_#VXUbZzSe^Zm?<>I=XlOvAKL-W*PG$oQCKko+yR#sN}eNVnDa_i?20|NskB_%zy zJWh?aM-8>4*^c}aU6>juL$(?E7rVT-%IKjj?$|l*c+N~=7$TqE-s``6dgiCTspVSt&|;~D3hKs+b-IOMuV4|*B`I$A|7QWoc6WCV|48@l zF7vbPE~O#m*$v3|R6N9bqI!nqh;~}d_v05+u6!>jbVWPpojZ44UHNbTW3g9CDtZmg zMv{=x7Y4L@vdFlJqXn1TY7%FJ=bQg{=jAdz*nlC&Vt)7GLwQAouc%q`Hdai}nA)>F1v3RjaF2#yjH`NP^q71&tLNXP4DQ(VQV`1MEjqcszc%{ww8?$gt% z=UBksrqIGeyX`~0AJL{@K_GuNmd?+v2eZp7BvZJ6fLsr*wS#AuvHt2SwU z$SPK6@KcBm>oq+yv#hrEj2HH?f{V*BPWP$pA3uI{o9~Ng%dz%eSzgpY3-);pjYt{y zV?2tFKGoH2xg8~DNdXR0z8p*&AIa{tq4X+O1abx@B=A@kFK6Yp`)p#)a2x|556EX4 z*VfDdJDz3tEj!8XAHSF@a+IaXdfS;{E%x=Ov)aWTF&pUje$6ydd%!GMhhZO|p6>f}pB_EN@620; zQ6+0Bo$j;j>gwwFRoCd~S+6xT=x~35?PeU=nrV{hvFvi-;+vD`#@@ty=txB2+;4w= zoq{LWu|%IbdYR>ojbBEN)6meQ8&+FZBck^-SY44x?>V zp?n(r;hnj*en6r~fZ5~VV0jcRWfc`+`XcmxM;g^*0R1>U)|aGQ&A!nPPeTH(B;tpD z7wlnK{8hvsSd_+L|`L z*g(Vqkg&SZF<;3~fa}8Sm>NPZCnhHo>;@Xwu3Psl`LbXpQS;{qlhas^nzQKZ>l?@Y zAv*Xm67dNM&LbaM@b&qA#9i|17PV<-W0vmv`H8sAS0#1AP|r)(juKeTGpr6N&gu@oIavGpey4TJWQa*@_W3fOSKqS?D4vSTMfkV)CW( zy1GYH)U>@?x~N{DA_je5!j^_~SGoVGwL<_cjYQLLFPUWD2C_@imQ)KC*I=zm*!I&U zsl^$Obrenww@!*>f6p*doy^S67W3P}zGk%J@axNIvCf-Xnw!Hh!T|Hd{Gmgd?{0T> z>D}@6*1~>67D8Kr^S9sOW6Pe@x5L81oU3O(77t}(xcsoZP;KPU1OFo*=~df!-6*e| zU0n?g4NsgtO@}di;l4P-a7gh4#5Q@;<{7~wD$2NS-8#|(20nQ(fel-? z?nBi&bS>{(j#YPOTXUg36@*w-T^%j4A(BZS9xnYGJeb!d@ud&twLH!V6lI%1J#5LwN^_G)%{wv)JU0vuEY3qNIddL)OO z+z=;q)5F7~=E-4iN?&!)@NhtJv24gbwTkxk_IOTZJv}aL!rtVxqIf_8f=sE-rD{uH zGEBlHt-459;knJz^W-=Yy%N%1wb+`2X$5yad^jt*-FX33mg@HKuz7QuuBG)Rkv)6v zBGm%5j~L>b`PkUl{+_oh_pmI-C+lTxe}CH0Wl@|f?K*P~fA74OtzYJAyh#^h%Lkr@ z1xgfd(sd)F$aeQpM@wg8oPsi;WL%Ic4^H}7>@k4DVT z&7FI7ZZ~k+v@IuHq8O16NXgyA$;n9|lr8C+&dsJ*9}z3@`eHH@(0DJP8leA#dd~_g z{t9M`UD{KI}$rbNXN zj1tVN8r*XE4`8e{>lQ0&AusYnDs5F)Q$dN8TQhiHjb2m!==74vmFB`) zZ6o-$ZLQksYVfDw!v|18Po>xA%b!pt*3kUkO{hW9xRm(gQnW7osxpqt3!5jWk5Q}` z4)g{9(g*_i4^QkJc=q3AYp?Q4ut-Y8PB9N!pKBY5H<-}f@nmZBTw9wqLxAG-%2e;@ zGb-v_9P!}-?$-9-?d3MaX|8v*6-KJdWst|5A0ekBDNH__B=}eYZ@W$y&~f}Y0fGp1 za4I|~@wn8NyOxVoF0&rW_+^&GuliP7K9#x9@hWeXh(obM{`r0NkF}|uik5FvSY;ABg3~!Sc6R32s z>_|r;A<_w4cbUTGVnSWe>Aj~^!i zo|oo&0*xcFQzzPN>$kJ8D4jkXAR)`ctZA)YeecF z<9o5-rmfkQoFv(mIUbOVL~JplRPt%O{IkdT0q@?u8P}_JAetw1hyqQWY5HyNrMH?N z5ua2$4~-Tr<=hzGhQJ;Hk8SKyPLBaP1h+$#sx?}-g{`Elj2f1NZAY#xSu@LgC%HZ@ z`;{#E+`$=zfx?*bMCzA!)nZSk70&EJFi#Sw*#&_6b{++*#hF{AvoXg`RX$=T1`=(;nAGjrz1s^2(gXM!?=Hhk5Meq1MnFV!@6q|RtO`iD)y8@Mo@7yyIqMgL9s?e3`^?nW- z#_}fo8#o4;;GHr1)tmTYYYxq*LhPi*BQIkbOh^U18+shlgSvPGv%LW%usi zKRgVIfe*h6Gbe(qcT;eRozN49>B@xUPn_6nVq!vs*;h>TR6Yb3VgcbifD9f{El{G24hboI2DY2&9qEopHQg{Y@x z(3qd=>W)H)29)}2VuceRhL^+^H(X0g`;@#>PdneCR(tlBWw9s%s%{Vz5f_VCeGwGJ z05GpzsEWP`2?^;pzaPk4!sdPGxzxkytIwj~`R=jcy zSDZE1cRAO_sI;{7m3FQ$GN*c=#kh_h4d?Wj^CFZl0QkU#S0z8v4f%Am)9!>6h~D^G zvNVjiG(0=UZeRz^CZ-oA^|3WEk`dsVfM^fLukwg%v$C^W`wRq%fnSrju|Zv|v?{u7 zU^W@If?`-14To~8i~-L4h~28`1po93R%_E)L@y`y0goANEI(pHo@n724A6H zpcU6cP*shTE25LcEen)YRWs*UCvCb)sBX5To&g67t>8|I1_s=iwhL)G?FF}`G%xka5gS3avbgKy)UjpS_rx z*EZ16U56khEJQ#BX&WPBxvHR6(Ym>nRchR+g6Y>F^3x!uuTTAHKtkPJVB-~jW{A7H z9t}+U&yj#PlWS}|?=Ukn`$a`XNi0KbK8gF**eLEg!;io;WS&{$t`z5Gz5 zkA+xE7DL8jBalM&ePDWeIvg}%1j&J$C|6fncnD1jI>~lz^++l3eE}&r3n~VN1Y&70n;3Gb;MQ71NtVuYi+{m-TEjDoB zCjj$|R#o3O2^J8Q6l%7jyqvePEaZf|JaY23$;rtvO?JXuf{as;m%no=X{w+mS|S9s zy?bDw3U%G|SC#-Q^Er)0HYO&bEbwV%>_NpO5)Z|X9V7e{qD&*YkZ`N2s&E?*I>?|& zzr3TU#tldolv!P}#kHLQFNCtbb2d2XD*%z8TPO_C;?{TIO`pSwjK45J9y1m5bUzlHN-H`Wnz_wHH%%KnRQLdzp2yl2KbEj?ytXUVX4_VDtm zXPImH+d!L(DmGS!Sblvbmy?&50p^N9D91R~+QLiqLe*(VQsXRIqNt*ZL$!spmXbk- zp#A3H9HwVyn_xiTQc<77ToTS7$OmrQ;x^Tw_};uV7^~gQ-CZD$0jAqksf@&P&*cMH z#kS=9dQb$ySz1;m;V{gCebrmZ>3O8yQC3#gf5nTkwlOEHxPE^-p523Tr{LyR5EK)0 z>ySSv2!!TlltbVpD_GWhO|c_Knw*AGvWXgrGl(9-Ys!iX3etbn|E2zj%Irj7TTzjQ@C=g%&r0K*aR53w+{aNaPZ=r3sv@7H*X$5-E+zq|^-pVSmT1 zE}6tuOWOT;Mne3g)W<1JBBU`WDH`9U=oJh>VnBo8K8AaKy)6udJ}00)rNbIi2R2RW zt4KI%$vNF!THa;VfBFFgvDBB!D4AaKpg>E231ycg~B|9f=o>~iC)@BjNG`F}VrfEh(R=YRUk|8Ve2$u`P|>j~HC ztrGDxDIdb5ev3(D*!cHTM_3Rcod5Z9!IrHuqwG$r%d^(j*81S?q!)>ahhbjDqQv5! zJ#|Q76?dC^SAI(*+u{r~7~xNm z;A6(aLa-Zbs?tVXrn!xSN#cBz5DOqvnni9BMMXtWnHnILB#)&DMX0-ZY@!XDH`8>X zauYhSe`aR1U6oJ~4e>q_^*D{Yja?1;kSg-;+o$2+1DcN)uLZ27Hyc`QyOoMVM+i;l z)L$T`!y_XTv$Ok9#ZitM3SAv-=ZDgY>)?rjRCD@lWc*^!3Y}D}<%jY89CAQ+s#{e_ z=^3ad!FP!BOzu|id;uGbaer}WXsC9!?Zod>=g*(tf9dU$ayMAVpv2WM=P7M|(5+XR ztnudFfSX{uh)Yg29UnW&nfM4 z1XZKfb{SLvE?$Rz z+qUB_E(@L?JfH&CZ`?@O*~9Jm5#Vv;D;B>$-3LJX0Z7@Mu`Ja~XT^hyptCvl?Mspk zo1$XiJAW5d4W)+#-k~jjnr1q;`@+(wyBWMczh7LzJle(AyN}B?# zSzmqP{BVduoH(Q4vrW2A4;ragE@!)g!0?AdwOE6k#z+$bdvb1-?bmh7pC*x&xV;%k N`PA8ysq%Vv{tM4Re_8+l literal 0 HcmV?d00001 diff --git a/doc/devel/images/overview.svg b/doc/devel/images/overview.svg new file mode 100644 index 0000000..8bab27c --- /dev/null +++ b/doc/devel/images/overview.svg @@ -0,0 +1,436 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + KERNEL + + SIMProtocol + WVdialProtocol + + SIMCardConnection + + SIMCardConnWrapper + + HardwareRegistry + + DeviceListener + + wvdial + + pppd + + twisted + + python-serial + + python-dbus + + dbus/hal + + CORE + + + Models + + GUI + + Controllers + + Views + + diff --git a/doc/devel/index.rst b/doc/devel/index.rst new file mode 100644 index 0000000..eaf89d0 --- /dev/null +++ b/doc/devel/index.rst @@ -0,0 +1,16 @@ +.. _devel-index: + +============================ +Documentation for developers +============================ + +.. rubric:: Everything you need to know about Wader + + +.. toctree:: + :maxdepth: 1 + + overview + usage + add-new-device + add-new-os diff --git a/doc/devel/overview.rst b/doc/devel/overview.rst new file mode 100644 index 0000000..44e926e --- /dev/null +++ b/doc/devel/overview.rst @@ -0,0 +1,45 @@ +============== +Wader overview +============== + +Introduction +============ + +Wader is a 3G device manager daemon written in Python and tested on Linux and +MacOS X (10.5 with recent `MacPorts`_). + +Some of its features are: + +- Pluggable architecture that can be extended by plugins +- Make mobile data connections over the network +- Manage your SIM contacts and SMS +- Operator-agnostic +- Implements the :term:`ModemManager` API. Thus writing a device plugin for Wader + means that your device will work with the *de-facto* tool for managing + networks +- Service actionable via :term:`DBus` + +.. _MacPorts: http://www.macports.org/ + +History +======= + +`Warp Networks`_ developed `Vodafone Mobile Connect Card driver for Linux`_ +for `Vodafone Spain R&D`_ between late 2006 and early 2008. + +When the project ended, we realized that there were some parts of the +application, like the core, that were pretty good and potentially useful for +other applications of the Linux/Unix desktop ecosystem that wanted to +interact with 3G devices. + +So Warp decided to fork it, rewrite some dodgy spots and export all its juicy +bits over :term:`DBus`, which was the missing piece in order to be able to +talk with other applications of the Linux desktop. The project needed a new +name and we chose `Wader`_ for it. + +.. _Warp Networks: http://www.warp.es/ +.. _Vodafone Mobile Connect Card driver for Linux: http://www.betavine.net/web/linux_drivers/ +.. _Vodafone Spain R&D: http://www.vodafone.es/ +.. _Wader: http://www.wader-project.org/ + + diff --git a/doc/devel/usage.rst b/doc/devel/usage.rst new file mode 100644 index 0000000..d2ec82a --- /dev/null +++ b/doc/devel/usage.rst @@ -0,0 +1,316 @@ +================================ +How to use Wader in your project +================================ + +ModemManager API +================ + +Wader is the first project, apart from :term:`ModemManager` itself, that +implements :term:`ModemManager`'s API. This means that Wader can be +seamlessly integrated with :term:`NetworkManager` 0.7.1+. + +Wader DBus overview +=================== + +Wader is a system service, running under a privileged uid. Wader is started +automatically the first time you invoke the Wader service:: + + dbus-send --system --dest=org.freedesktop.ModemManager --print-reply \ + /org/freedesktop/ModemManager org.freedesktop.ModemManager.EnumerateDevices + method return sender=:1.193 -> dest=:1.189 reply_serial=2 + array [ + object path "/org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial" + ] + +Wader has found a Huawei E220 present in the system. The ``EnumerateDevices`` +method call returns an array of object paths. + +The next operation should **always** be +``org.freedesktop.ModemManager.Modem.Enable``. This method receives a boolean +argument indicating whether the device should be enabled or not:: + + dbus-send --system --dest=org.freedesktop.ModemManager --print-reply \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Modem.Enable boolean:true + Error org.freedesktop.ModemManager.Modem.Gsm: SimPinRequired: \ + org.freedesktop.ModemManager.Error.PIN: org.freedesktop.ModemManager.Error.PIN: + +In this case, the ``Enable`` operation has raised an exception, PIN/PUK is +needed. The ``Enable`` machinery, and its state, will be resumed if we send +the correct PIN/PUK. Had we been previously authenticated or PIN/PUK were not +enabled, the method would not have returned anything:: + + dbus-send --system --dest=org.freedesktop.ModemManager --print-reply \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Modem.Gsm.Card.SendPin string:0000 + method return sender=:1.193 -> dest=:1.191 reply_serial=2 + +After successfully entering the PIN, the device is given about fifteen seconds +to settle and perform its internal setup. After this point you can interact +with the device as you please. + +.. note:: + + There are plans to create a `device-specific interface`_ that will ask + the device when its ready rather than just giving 15 seconds and hoping + that it will suffice. + + .. _device-specific interface: http://public.warp.es/wader/ticket/77 + +And how do you obtain the UDIs of the devices then? ++++++++++++++++++++++++++++++++++++++++++++++++++++ + +Wader internally interacts with :term:`Hal` and requests the UDIs of all the +devices that have modem capabilities. The command +``FindDeviceByCapability("modem")`` returns the object paths of the devices +tagged with the modem capability. Armed with this, Wader obtains all the +serial ports associated with this device, and builds a ``DevicePlugin`` +out of it. + +Wader will emit a ``DeviceAdded`` :term:`DBus` signal when a new 3G device +has been added, and a ``DeviceRemoved`` signal when a 3G device has been +removed. + +As mentioned above, the method +``org.freedesktop.ModemManager.EnumerateDevices`` returns an array of object +paths, one for each device found in the system:: + + dbus-send --system --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/ModemManager org.freedesktop.ModemManager.EnumerateDevices + method return sender=:1.156 -> dest=:1.155 reply_serial=2 + array [ + object path "/org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial" + object path "/org/freedesktop/Hal/devices/pci_1931_c" + object path "/org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial_0" + ] + +This is the response of ``EnumerateDevices`` with a Huawei E172, an E870 and an +Option GlobeTrotter 3G+ (Nozomi). Wader is able to detect, configure and use the +three at the same time with no interference between them whatsoever. + +Operations you might want to do on a device +=========================================== + +Once your device is set up, you will probably want to register with a given +operator, or perhaps letting Wader to choose itself? Perhaps you want to do a +high-level operation such as configuring the band and connection mode? We are +going to provide examples for every one of them: + +Registering to a network +++++++++++++++++++++++++ + +Registering to a network will not be necessary most of the time as the +devices themselves will register to its home network. Manually specifying +a :term:`MNC` to connect to an arbitrary network is possible, though:: + + dbus-send --type=method_call --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1001_HUAWEI_DEVICE \ + org.freedesktop.ModemManager.Modem.Gsm.Network.Register string:21401 + method return sender=:1.193 -> dest=:1.193 reply_serial=2 + +It is also possible to pass an empty string, and that will register to the +home network:: + + dbus-send --system --dest=org.freedesktop.ModemManager --print-reply \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Modem.Gsm.Network.Register string: + method return sender=:1.193 -> dest=:1.195 reply_serial=2 + + dbus-send --system --dest=org.freedesktop.ModemManager --print-reply \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Modem.Gsm.Network.GetRegistrationInfo + method return sender=:1.193 -> dest=:1.196 reply_serial=2 + struct { + uint32 1 + string "21401" + string "vodafone ES" + } + +The :term:`MNC` ``21401`` is Vodafone Spain's MNC, my current network +provider. If I try to connect to Telefonica's :term:`MNC` ``21407``, the +operation will probably horribly fail:: + + dbus-send --type=method_call --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1001_HUAWEI_DEVICE \ + org.freedesktop.ModemManager.Modem.Gsm.Network.Register string:21407 + method return sender=:1.193 -> dest=:1.197 reply_serial=2 + +Oops, it didn't fail :). Although if I try to connect to Internet it will +fail for sure as the APN is completely different. + +Configuring connection settings ++++++++++++++++++++++++++++++++ + +You might be interested on changing the connection mode from 2G to 3G. Or +perhaps you are interested on changing from `GSM1900` to `GSM850` if you are +roaming. Whatever your needs are, you are looking for the +``org.freedesktop.ModemManager.Modem.Gsm.Network.SetBand`` and +``org.freedesktop.ModemManager.Modem.Gsm.Network.SetNetworkMode`` +methods. This methods and their parameters are thoroughly described in +:term:`ModemManager`'s excellent API. + +Sending a SMS ++++++++++++++ + +Sending a SMS can not be any easier:: + + from wader.common.sms import Message, sms_to_dict + + ... + + def sms_cb(indexes): print "SMS sent spanning", indexes + def sms_eb(e): print "Error sending SMS", e + + sms = Message("+34606575119", "hey dude") + device.Send(sms_to_dict(sms), + dbus_interface=consts.SMS_INTFACE, + reply_handler=sms_cb, + error_handler=sms_eb) + +And sending an UCS2 encoded SMS can't get any easier either:: + + from wader.common.sms import Message, sms_to_dict + + ... + + def sms_cb(indexes): print "SMS sent spanning", indexes + def sms_eb(e): print "Error sending SMS", e + + sms = Message("+34606575119", "àèìòù") + device.Send(sms_to_dict(sms), + dbus_interface=consts.SMS_INTFACE, + reply_handler=sms_cb, + error_handler=sms_eb) + +Adding/Reading a Contact +++++++++++++++++++++++++ + +Adding a contact to the SIM and getting the index where it was stored:: + + dbus-send --system --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Contacts.Add string:Pablo string:+34545665655 + method return sender=:1.54 -> dest=:1.57 reply_serial=2 + uint32 1 + +And reading it again:: + + dbus-send --system --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Contacts.Get uint32:1 + method return sender=:1.54 -> dest=:1.58 reply_serial=2 + struct { + uint32 1 + string "Pablo" + string "+34545665655" + } + +Now lets add another contact and read all the contacts in the SIM card:: + + dbus-send --system --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Contacts.Add string:John string:+33546657784 + method return sender=:1.54 -> dest=:1.60 reply_serial=2 + uint32 2 + dbus-send --system --print-reply --dest=org.freedesktop.ModemManager \ + /org/freedesktop/Hal/devices/usb_device_12d1_1003_noserial \ + org.freedesktop.ModemManager.Contacts.List + method return sender=:1.54 -> dest=:1.61 reply_serial=2 + array [ + struct { + uint32 1 + string "Pablo" + string "+34545665655" + } + struct { + uint32 2 + string "John" + string "+33546657784" + } + ] + +Data calls +========== + +Connecting to the Internet just requires knowing the UDI of a device with +modem capabilities and a profile. While the former is obtained through +a ``FindDeviceByCapability("modem")``, the latter requires to create it +explicitly. Be it through Wader or :term:`NetworkManager`, a profile is +required. + +Profile creation +++++++++++++++++ + +Profiles in Wader, known as connections in :term:`NetworkManager` lingo, are +stored using the `GConf`_ configuration system. When a new profile is +written to gconf, a new profile -or connection- is created and exported +on :term:`DBus`. When the profile is ready to be used, a ``NewConnection`` +signal is emitted with the profile object path as its only argument. + +.. _GConf: http://projects.gnome.org/gconf/ + +Connecting +++++++++++ + +Armed with the object paths of the profile and device to use, we just +need to pass this two arguments to +:meth:`~wader.common.dialer.DialerManager.ActivateConnection`. Under the +hood this method will perform the following: + +- Get a :class:`~wader.common.dialer.Dialer` instance for this device. If + :term:`NetworkManager` 0.7.1+ is present, it will use + :class:`~wader.common.dialers.nm_dialer.NMDialer`, if the device happens + to be an HSO device it will use + :class:`~wader.common.dialers.hsolink.HSODialer`, otherwise it will just + use :class:`~wader.common.dialers.wvdial.WVDialDialer`. +- Configure the given device with the profile settings. If the profile + specifies a band or a network mode, the band or network mode will be set + through ``SetBand`` and ``SetNetworkMode``. After waiting a couple of + seconds so the device can settle, the actual connection process will be + started. +- The dialer will obtain from the profile the needed settings to connect: + apn, username, whether DNS should be static or not, etc. Obtaining the + password associated with a profile is a different story though. Passwords + are stored in `gnome-keyring-daemon` through the :class:`gnomekeyring` + module, every profile has an :term:`UUID` that identifies it uniquely. All + this is abstracted in the module :class:`~wader.gtk.secrets`. + +If ``ActivateConnection`` succeeds, it will return the object path of the +connection, connections are identified by it and its required to save +somewhere this object path to stop the connection later on. + +Disconnecting ++++++++++++++ + +Disconnecting could not be easier, you just need to pass the object path +returned by ``ActivateConnection`` to +:meth:`~wader.common.dialer.DialerManager.DeactivateConnection`. This will +deallocate all the resources allocated by ``ActivateConnection``. + +Troubleshooting +=============== + +Operation X failed on my device ++++++++++++++++++++++++++++++++ + +Every device its a world on its own, sometimes they are shipped with a buggy +firmware, sometimes a device will reply to a command on a slightly different +way that will break the parsing of the reply. + +Wader ships with a test suite that might yield some clues about what went +wrong. Instructions to execute it:: + + trial -r gtk2 wader.test + +Do not forget the :option:`-r gtk2` switch, it will pick the `gtk2` reactor +to run the tests, otherwise all the glib-dependent tests, like the +:meth:`DBus` ones will fail. + +.. note:: + + Since Wader migrated to a DBus architecture, the tests related to the + device or DBus no longer work. We think that this was introduced in + trial, the tool that the twisted framework provides to run unit tests. So + for now those tests are broken, the goal is to bring them back to life + around the 0.4.0 release. + diff --git a/doc/glossary.rst b/doc/glossary.rst new file mode 100644 index 0000000..b634816 --- /dev/null +++ b/doc/glossary.rst @@ -0,0 +1,48 @@ +.. _glossary: + +======== +Glossary +======== + +.. glossary:: + :sorted: + + NetworkManager + This tool has managed to be the de-facto `network configuration`__ + tool in the last years on the Linux desktop. Almost all modern + Linux distros rely on it. + + __ http://projects.gnome.org/NetworkManager/ + + ModemManager + `An API defined`__ between the NetworkManager and Wader project to + communicate with mobile broadband (GSM, CDMA, UMTS, ...) cards. It + implements a loadable plugin interface to add work-arounds for non + standard devices. + + __ http://trac.warp.es/wader/wiki/WhatsModemManager + + MNC + `Mobile Network Code`__ uniquely identifies an operator. + + __ http://en.wikipedia.org/wiki/Mobile_Network_Code + + DBus + `A message bus system`__, a simple way for applications to talk to one + another. It has replaced similar technologies such as Bonobo (Gnome) + or DCOP (KDE). + + __ http://www.freedesktop.org/wiki/Software/dbus + + Hal + `Hardware abstraction layer`__, an interface between hardware and + software that abstracts differences between heterogeneous devices + and provides hints about their capabilities. + + __ http://en.wikipedia.org/wiki/Mobile_Network_Code + + UUID + `Universally unique identifier`__. + + __ http://en.wikipedia.org/wiki/Universally_Unique_Identifier + diff --git a/doc/modules/_dbus.rst b/doc/modules/_dbus.rst new file mode 100644 index 0000000..720747d --- /dev/null +++ b/doc/modules/_dbus.rst @@ -0,0 +1,18 @@ +:mod:`wader.common._dbus` +========================= + +.. automodule:: wader.common._dbus + +Classes +-------- + +.. autoclass:: DBusComponent + :members: + +.. autoclass:: DBusExporterHelper + :members: + +.. autoclass:: DelayableDBusObject + :members: + +.. autofunction:: delayable diff --git a/doc/modules/aterrors.rst b/doc/modules/aterrors.rst new file mode 100644 index 0000000..9115196 --- /dev/null +++ b/doc/modules/aterrors.rst @@ -0,0 +1,175 @@ +:mod:`wader.common.aterrors` +============================ + +.. automodule:: wader.common.aterrors + +Functions +--------- + +.. autofunction:: error_to_human + +.. autofunction:: extract_error + +Exceptions +---------- + +.. autoexception:: GenericError + +.. autoexception:: InputValueError + +.. autoexception:: SerialResponseTimeout + +.. autoexception:: PhoneFailure + +.. autoexception:: NoConnection + +.. autoexception:: LinkReserved + +.. autoexception:: OperationNotAllowed + +.. autoexception:: OperationNotSupported + +.. autoexception:: PhSimPinRequired + +.. autoexception:: PhFSimPinRequired + +.. autoexception:: PhFPukRequired + +.. autoexception:: SimNotInserted + +.. autoexception:: SimPinRequired + +.. autoexception:: SimPukRequired + +.. autoexception:: SimFailure + +.. autoexception:: SimBusy + +.. autoexception:: SimWrong + +.. autoexception:: SimNotStarted + +.. autoexception:: IncorrectPassword + +.. autoexception:: SimPin2Required + +.. autoexception:: SimPuk2Required + +.. autoexception:: MemoryFull + +.. autoexception:: InvalidIndex + +.. autoexception:: NotFound + +.. autoexception:: MemoryFailure + +.. autoexception:: TextTooLong + +.. autoexception:: InvalidChars + +.. autoexception:: DialStringTooLong + +.. autoexception:: InvalidDialString + +.. autoexception:: NoNetwork + +.. autoexception:: NetworkTimeout + +.. autoexception:: NetworkNotAllowed + +.. autoexception:: NetworkPinRequired + +.. autoexception:: NetworkPukRequired + +.. autoexception:: NetworkSubsetPinRequired + +.. autoexception:: NetworkSubsetPukRequired + +.. autoexception:: ServicePinRequired + +.. autoexception:: ServicePukRequired + +.. autoexception:: CharsetError + +.. autoexception:: CorporatePinRequired + +.. autoexception:: CorporatePukRequired + +.. autoexception:: HiddenKeyRequired + +.. autoexception:: EapMethodNotSupported + +.. autoexception:: IncorrectParams + +.. autoexception:: Unknown + +.. autoexception:: GprsIllegalMs + +.. autoexception:: GprsIllegalMe + +.. autoexception:: GprsServiceNotAllowed + +.. autoexception:: GprsPlmnNotAllowed + +.. autoexception:: GprsLocationNotAllowed + +.. autoexception:: GprsRoamingNotAllowed + +.. autoexception:: GprsOptionNotSupported + +.. autoexception:: GprsNotSubscribed + +.. autoexception:: GprsOutOfOrder + +.. autoexception:: GprsPdpAuthFailure + +.. autoexception:: GprsUnspecified + +.. autoexception:: GprsInvalidClass + +.. autoexception:: ServiceTemporarilyOutOfOrder + +.. autoexception:: UnknownSubscriber + +.. autoexception:: ServiceNotInUse + +.. autoexception:: UnknownNetworkMessage + +.. autoexception:: CallIndexError + +.. autoexception:: CallStateError + +.. autoexception:: CMSError300 + +.. autoexception:: CMSError301 + +.. autoexception:: CMSError302 + +.. autoexception:: CMSError303 + +.. autoexception:: CMSError304 + +.. autoexception:: CMSError305 + +.. autoexception:: CMSError310 + +.. autoexception:: CMSError311 + +.. autoexception:: CMSError313 + +.. autoexception:: CMSError314 + +.. autoexception:: CMSError315 + +.. autoexception:: CMSError320 + +.. autoexception:: CMSError321 + +.. autoexception:: CMSError322 + +.. autoexception:: CMSError330 + +.. autoexception:: CMSError331 + +.. autoexception:: CMSError500 + diff --git a/doc/modules/command.rst b/doc/modules/command.rst new file mode 100644 index 0000000..4e39779 --- /dev/null +++ b/doc/modules/command.rst @@ -0,0 +1,18 @@ +:mod:`wader.common.command` +=========================== + +.. automodule:: wader.common.command + +Classes +------- + +.. autoclass:: ATCmd + :members: + +Functions +--------- + +.. autofunction:: get_cmd_dict_copy() + +.. autofunction:: build_cmd_dict(extract, end, error) + diff --git a/doc/modules/config.rst b/doc/modules/config.rst new file mode 100644 index 0000000..7fffb5f --- /dev/null +++ b/doc/modules/config.rst @@ -0,0 +1,11 @@ +:mod:`wader.common.config` +=========================== + +.. automodule:: wader.common.config + +Classes +------- + +.. autoclass:: WaderConfig + :members: + diff --git a/doc/modules/contact.rst b/doc/modules/contact.rst new file mode 100644 index 0000000..d4449a4 --- /dev/null +++ b/doc/modules/contact.rst @@ -0,0 +1,13 @@ +:mod:`wader.common.contact` +=========================== + +.. automodule:: wader.common.contact + +Classes +-------- + +.. autoclass:: Contact + :show-inheritance: + :members: + :inherited-members: + :undoc-members: diff --git a/doc/modules/daemon.rst b/doc/modules/daemon.rst new file mode 100644 index 0000000..0982877 --- /dev/null +++ b/doc/modules/daemon.rst @@ -0,0 +1,31 @@ +:mod:`wader.common.daemon` +========================== + +.. automodule:: wader.common.daemon + +Classes +-------- + +.. autoclass:: WaderDaemon + :members: + +.. autoclass:: SignalQualityDaemon + :show-inheritance: + :members: + +.. autoclass:: NetworkSpeedDaemon + :show-inheritance: + :members: + +.. autoclass:: NetworkRegistrationDaemon + :show-inheritance: + :members: + +.. autoclass:: WaderDaemonCollection + :members: + +Functions +--------- + +.. autofunction:: build_daemon_collection + diff --git a/doc/modules/dialer.rst b/doc/modules/dialer.rst new file mode 100644 index 0000000..d190e7a --- /dev/null +++ b/doc/modules/dialer.rst @@ -0,0 +1,22 @@ +:mod:`wader.common.dialer` +========================== + +.. automodule:: wader.common.dialer + +Classes +-------- + +.. autoclass:: DialerConf + :members: + :undoc-members: + +.. autoclass:: Dialer + :members: + :show-inheritance: + :undoc-members: + +.. autoclass:: DialerManager + :members: + :show-inheritance: + :undoc-members: + diff --git a/doc/modules/dialers/hsolink.rst b/doc/modules/dialers/hsolink.rst new file mode 100644 index 0000000..e44cb56 --- /dev/null +++ b/doc/modules/dialers/hsolink.rst @@ -0,0 +1,13 @@ +:mod:`wader.common.dialers.hsolink` +=================================== + +.. automodule:: wader.common.dialers.hsolink + +Classes +-------- + +.. autoclass:: HSODialer + :members: + :show-inheritance: + :undoc-members: + diff --git a/doc/modules/dialers/nm_dialer.rst b/doc/modules/dialers/nm_dialer.rst new file mode 100644 index 0000000..b3cc3f3 --- /dev/null +++ b/doc/modules/dialers/nm_dialer.rst @@ -0,0 +1,13 @@ +:mod:`wader.common.dialers.nm_dialer` +===================================== + +.. automodule:: wader.common.dialers.nm_dialer + +Classes +-------- + +.. autoclass:: NMDialer + :members: + :show-inheritance: + :undoc-members: + diff --git a/doc/modules/dialers/wvdial.rst b/doc/modules/dialers/wvdial.rst new file mode 100644 index 0000000..4940f28 --- /dev/null +++ b/doc/modules/dialers/wvdial.rst @@ -0,0 +1,25 @@ +:mod:`wader.common.dialers.wvdial` +================================== + +.. automodule:: wader.common.dialers.wvdial + +Classes +-------- + +.. autoclass:: WVDialDialer + :members: + :show-inheritance: + :undoc-members: + +.. autoclass:: WVDialProtocol + :members: + :show-inheritance: + :undoc-members: + +Functions +--------- + +.. autofunction:: get_wvdial_conf_file(conf, serial_port) + +.. autofunction:: _generate_wvdial_conf(conf, sport) + diff --git a/doc/modules/encoding.rst b/doc/modules/encoding.rst new file mode 100644 index 0000000..3b64cc3 --- /dev/null +++ b/doc/modules/encoding.rst @@ -0,0 +1,21 @@ +:mod:`wader.common.encoding` +============================ + +.. automodule:: wader.common.encoding + +Functions +--------- + +.. autofunction:: pack_ucs2_bytes(s) + +.. autofunction:: unpack_ucs2_bytes(s) + +.. autofunction:: check_if_ucs2(s) + +.. autofunction:: from_u(s) + +.. autofunction:: from_ucs2(s) + +.. autofunction:: to_u(s) + + diff --git a/doc/modules/exceptions.rst b/doc/modules/exceptions.rst new file mode 100644 index 0000000..856a889 --- /dev/null +++ b/doc/modules/exceptions.rst @@ -0,0 +1,20 @@ +:mod:`wader.common.exceptions` +============================== + +.. automodule:: wader.common.exceptions + +Exceptions +---------- + +.. autoexception:: DeviceLockedError + +.. autoexception:: LimitedServiceNetworkError + +.. autoexception:: MalformedSMSError + +.. autoexception:: NetworkRegistrationError + +.. autoexception:: ProfileNotFoundError + +.. autoexception:: UnknownPluginNameError + diff --git a/doc/modules/exported.rst b/doc/modules/exported.rst new file mode 100644 index 0000000..4800e88 --- /dev/null +++ b/doc/modules/exported.rst @@ -0,0 +1,32 @@ +:mod:`wader.common.exported` +============================ + +.. automodule:: wader.common.exported + +Classes +-------- + +.. autoclass:: ModemExporter + :members: + +.. autoclass:: SimpleExporter + :members: + +.. autoclass:: CardExporter + :members: + +.. autoclass:: NetworkExporter + :members: + +.. autoclass:: SMSExporter + :members: + +.. autoclass:: ContactsExporter + :members: + +.. autoclass:: WCDMAExporter + :members: + +.. autoclass:: HSOExporter + :members: + diff --git a/doc/modules/hardware/base.rst b/doc/modules/hardware/base.rst new file mode 100644 index 0000000..219b2c2 --- /dev/null +++ b/doc/modules/hardware/base.rst @@ -0,0 +1,16 @@ +:mod:`wader.common.hardware.base` +================================= + +.. automodule:: wader.common.hardware.base + +Classes +-------- + +.. autoclass:: WCDMACustomizer + :members: + :undoc-members: + +Function +-------- + +.. autofunction:: identify_device(port) diff --git a/doc/modules/hardware/huawei.rst b/doc/modules/hardware/huawei.rst new file mode 100644 index 0000000..0bc5ced --- /dev/null +++ b/doc/modules/hardware/huawei.rst @@ -0,0 +1,27 @@ +:mod:`wader.common.hardware.huawei` +=================================== + +.. automodule:: wader.common.hardware.huawei + +Classes +-------- + +.. autoclass:: HuaweiWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: HuaweiWrapper + :members: + :undoc-members: + +.. autoclass:: HuaweiSIMClass + :members: + :undoc-members: + +.. autoclass:: HuaweiCustomizer + :members: + :undoc-members: + +.. autoclass:: HuaweiWCDMADevicePlugin + :members: + :undoc-members: diff --git a/doc/modules/hardware/novatel.rst b/doc/modules/hardware/novatel.rst new file mode 100644 index 0000000..8ce71fb --- /dev/null +++ b/doc/modules/hardware/novatel.rst @@ -0,0 +1,15 @@ +:mod:`wader.common.hardware.novatel` +==================================== + +.. automodule:: wader.common.hardware.novatel + +Classes +-------- + +.. autoclass:: NovatelWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: NovatelWCDMADevicePlugin + :members: + :undoc-members: diff --git a/doc/modules/hardware/option.rst b/doc/modules/hardware/option.rst new file mode 100644 index 0000000..2d00c6a --- /dev/null +++ b/doc/modules/hardware/option.rst @@ -0,0 +1,31 @@ +:mod:`wader.common.hardware.option` +=================================== + +.. automodule:: wader.common.hardware.option + +Classes +-------- + +.. autoclass:: OptionWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: OptionHSOWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: OptionWrapper + :members: + :undoc-members: + +.. autoclass:: OptionSIMClass + :members: + :undoc-members: + +.. autoclass:: OptionWCDMADevicePlugin + :members: + :undoc-members: + +.. autoclass:: OptionHSOWCDMADevicePlugin + :members: + :undoc-members: diff --git a/doc/modules/hardware/sierra.rst b/doc/modules/hardware/sierra.rst new file mode 100644 index 0000000..34ae996 --- /dev/null +++ b/doc/modules/hardware/sierra.rst @@ -0,0 +1,15 @@ +:mod:`wader.common.hardware.sierra` +=================================== + +.. automodule:: wader.common.hardware.sierra + +Classes +-------- + +.. autoclass:: SierraWirelessWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: SierraWCDMADevicePlugin + :members: + :undoc-members: diff --git a/doc/modules/hardware/sonnyericsson.rst b/doc/modules/hardware/sonnyericsson.rst new file mode 100644 index 0000000..625f0a8 --- /dev/null +++ b/doc/modules/hardware/sonnyericsson.rst @@ -0,0 +1,11 @@ +:mod:`wader.common.hardware.sonyericsson` +========================================== + +.. automodule:: wader.common.hardware.sonyericsson + +Classes +-------- + +.. autoclass:: SonyEricssonCustomizer + :members: + :undoc-members: diff --git a/doc/modules/hardware/zte.rst b/doc/modules/hardware/zte.rst new file mode 100644 index 0000000..db6c6ec --- /dev/null +++ b/doc/modules/hardware/zte.rst @@ -0,0 +1,15 @@ +:mod:`wader.common.hardware.zte` +================================ + +.. automodule:: wader.common.hardware.zte + +Classes +-------- + +.. autoclass:: ZTEWCDMACustomizer + :members: + :undoc-members: + +.. autoclass:: ZTEWCDMADevicePlugin + :members: + :undoc-members: diff --git a/doc/modules/interfaces.rst b/doc/modules/interfaces.rst new file mode 100644 index 0000000..6232ded --- /dev/null +++ b/doc/modules/interfaces.rst @@ -0,0 +1,146 @@ +:mod:`wader.common.interfaces` +============================== + +.. automodule:: wader.common.interfaces + +Classes +-------- + +.. autoclass:: IContact + :show-inheritance: + + .. method:: to_csv() + + Returns a csv string with the contact info + +.. autoclass:: IMessage + :show-inheritance: + +.. autoclass:: ICollaborator + :show-inheritance: + + .. method:: get_pin() + + Returns the PIN + + :rtype: `Deferred` + + .. method:: get_puk() + + Returns a tuple with the PUK and PIN + + :rtype: `Deferred` + + .. method:: get_puk2() + + Returns a tuple with the PUK2 and PIN + + :rtype: `Deferred` + +.. autoclass:: IDialer + :show-inheritance: + + .. method:: configure(config, device) + + Configures the dialer with `config` for `device` + + :rtype: `Deferred` + + .. method:: connect() + + Connects to Internet + + :rtype: `Deferred` + + .. method:: disconnect() + + Disconnects from Internet + + :rtype: `Deferred` + + .. method:: stop() + + Stops an ongoing connection attempt + + :rtype: `Deferred` + +.. autoclass:: IWaderPlugin + :show-inheritance: + + .. method:: initialize() + + Initializes the plugin + + :rtype: `Deferred` + + .. method:: close() + + Closes the plugin + + :rtype: `Deferred` + +.. autoclass:: IDevicePlugin + :show-inheritance: + +.. autoclass:: IRemoteDevicePlugin + :show-inheritance: + +.. autoclass:: IOSPlugin + :show-inheritance: + + .. method:: is_valid() + + Returns True if we are in the given OS/distro + + :rtype: bool + + .. method:: add_default_route(iface) + + Sets ``iface`` as the default route + + .. method:: delete_default_route(iface) + + Unsets ``iface`` as the default route + + .. method:: add_dns_info(dnsinfo, iface=None) + + Sets up DNS ``dnsinfo`` for ``iface`` + + .. method:: delete_dns_info(dnsinfo, iface=None) + + Deletes ``dnsinfo`` from ``iface`` + + .. method:: configure_iface(iface, ip='', action='up') + + Configures `iface` with `ip` and `action` + + :param action: can be either 'up' or 'down' + :param ip: only used when ``action`` == 'up' + + .. method:: get_timezone() + + Returns the timezone of the OS + + :rtype: str + + .. method:: get_tzinfo(dnsinfo, iface=None) + + Returns a :class:`pytz.timezone` out the timezone + + +.. autoclass:: IHardwareManager + :show-inheritance: + + .. method:: get_devices() + + Returns a list with all the devices present in the system + + :rtype: `Deferred` + + .. method:: register_controller(controller) + + Registers ``controller`` as the driver class of this HW manager + + This reference will be used to emit Device{Add,Remov}ed signals + upon hotplugging events. + diff --git a/doc/modules/keyring.rst b/doc/modules/keyring.rst new file mode 100644 index 0000000..f4f128f --- /dev/null +++ b/doc/modules/keyring.rst @@ -0,0 +1,34 @@ +:mod:`wader.common.keyring` +=========================== + +.. automodule:: wader.common.keyring + +Exceptions +---------- + +.. autoexception:: KeyringNoMatchError + +.. autoexception:: KeyringInvalidPassword + +.. autoexception:: KeyringIsClosed + + +Classes +-------- + +.. autoclass:: KeyringManager + :members: + +.. autoclass:: GnomeKeyring + :members: + +.. autoclass:: AESKeyring + :members: + :show-inheritance: + + +Functions +--------- + +.. autofunction:: get_keyring_manager(base_gpath) + diff --git a/doc/modules/middleware.rst b/doc/modules/middleware.rst new file mode 100644 index 0000000..348b58b --- /dev/null +++ b/doc/modules/middleware.rst @@ -0,0 +1,16 @@ +:mod:`wader.common.middleware` +============================== + +.. automodule:: wader.common.middleware + +Classes +-------- + +.. autoclass:: WCDMAWrapper + :members: + +Functions +--------- + +.. autofunction:: regexp_to_contact + diff --git a/doc/modules/netspeed.rst b/doc/modules/netspeed.rst new file mode 100644 index 0000000..f1d6e16 --- /dev/null +++ b/doc/modules/netspeed.rst @@ -0,0 +1,16 @@ +:mod:`wader.common.netspeed` +============================ + +.. automodule:: wader.common.netspeed + +Classes +------- + +.. autoclass:: NetworkSpeed + :members: + +Functions +--------- + +.. autofunction:: bps_to_human(up, down) + diff --git a/doc/modules/oal.rst b/doc/modules/oal.rst new file mode 100644 index 0000000..866d498 --- /dev/null +++ b/doc/modules/oal.rst @@ -0,0 +1,10 @@ +:mod:`wader.common.oal` +============================ + +.. automodule:: wader.common.oal + +Functions +--------- + +.. autofunction:: get_os_object() + diff --git a/doc/modules/oses/linux.rst b/doc/modules/oses/linux.rst new file mode 100644 index 0000000..86509c1 --- /dev/null +++ b/doc/modules/oses/linux.rst @@ -0,0 +1,25 @@ +:mod:`wader.common.oses.linux` +============================== + +.. automodule:: wader.common.oses.linux + +Classes +-------- + +.. autoclass:: HardwareManager + :members: + :undoc-members: + +.. autoclass:: LinuxPlugin + :members: + :show-inheritance: + :undoc-members: + +Functions +--------- + +.. autofunction:: probe_port(port) + +.. autofunction:: probe_ports(ports) + +.. autofunction:: extract_info(props) diff --git a/doc/modules/oses/osx.rst b/doc/modules/oses/osx.rst new file mode 100644 index 0000000..1ac8da6 --- /dev/null +++ b/doc/modules/oses/osx.rst @@ -0,0 +1,17 @@ +:mod:`wader.common.oses.osx` +============================ + +.. automodule:: wader.common.oses.osx + +Classes +-------- + +.. autoclass:: HardwareManager + :members: + :undoc-members: + +.. autoclass:: OSXPlugin + :members: + :show-inheritance: + :undoc-members: + diff --git a/doc/modules/plugin.rst b/doc/modules/plugin.rst new file mode 100644 index 0000000..e60cbe0 --- /dev/null +++ b/doc/modules/plugin.rst @@ -0,0 +1,22 @@ +:mod:`wader.common.plugin` +========================== + +.. automodule:: wader.common.plugin + +Classes +-------- + +.. autoclass:: DevicePlugin + :show-inheritance: + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: RemoteDevicePlugin + :members: + +.. autoclass:: OSPlugin + :members: + +.. autoclass:: PluginManager + :members: diff --git a/doc/modules/profile.rst b/doc/modules/profile.rst new file mode 100644 index 0000000..92de97f --- /dev/null +++ b/doc/modules/profile.rst @@ -0,0 +1,17 @@ +:mod:`wader.common.profile` +=========================== + +.. automodule:: wader.common.profile + +Classes +-------- + +.. autoclass:: Profile + :members: + :show-inheritance: + :undoc-members: + +.. autoclass:: ProfileManager + :members: + :undoc-members: + diff --git a/doc/modules/protocol.rst b/doc/modules/protocol.rst new file mode 100644 index 0000000..b519d46 --- /dev/null +++ b/doc/modules/protocol.rst @@ -0,0 +1,17 @@ +:mod:`wader.common.protocol` +============================== + +.. automodule:: wader.common.protocol + +Classes +-------- + +.. autoclass:: BufferingStateMachine + :members: + +.. autoclass:: SerialProtocol + :members: + +.. autoclass:: WCDMAProtocol + :members: + diff --git a/doc/modules/secrets.rst b/doc/modules/secrets.rst new file mode 100644 index 0000000..22fa249 --- /dev/null +++ b/doc/modules/secrets.rst @@ -0,0 +1,10 @@ +:mod:`wader.common.secrets` +=========================== + +.. automodule:: wader.common.secrets + +Classes +-------- + +.. autoclass:: ProfileSecrets + :members: diff --git a/doc/modules/serialport.rst b/doc/modules/serialport.rst new file mode 100644 index 0000000..558066a --- /dev/null +++ b/doc/modules/serialport.rst @@ -0,0 +1,17 @@ +:mod:`wader.common.serialport` +============================== + +.. automodule:: wader.common.serialport + +Classes +-------- + +.. autoclass:: Port + :members: + +.. autoclass:: Ports + :members: + +.. autoclass:: SerialPort + :show-inheritance: + :members: diff --git a/doc/modules/shell.rst b/doc/modules/shell.rst new file mode 100644 index 0000000..7365e9b --- /dev/null +++ b/doc/modules/shell.rst @@ -0,0 +1,10 @@ +:mod:`wader.common.shell` +========================= + +.. automodule:: wader.common.shell + +Functions +--------- + +.. autofunction:: get_manhole_factory(namespace, **paswords) + diff --git a/doc/modules/sim.rst b/doc/modules/sim.rst new file mode 100644 index 0000000..6045c60 --- /dev/null +++ b/doc/modules/sim.rst @@ -0,0 +1,13 @@ +:mod:`wader.common.sim` +========================== + +.. automodule:: wader.common.sim + +Classes +-------- + +.. autoclass:: SIMBaseClass + :show-inheritance: + :members: + :inherited-members: + :undoc-members: diff --git a/doc/modules/sms.rst b/doc/modules/sms.rst new file mode 100644 index 0000000..94157af --- /dev/null +++ b/doc/modules/sms.rst @@ -0,0 +1,25 @@ +:mod:`wader.common.sms` +========================== + +.. automodule:: wader.common.sms + +Classes +-------- + +.. autoclass:: Message + :show-inheritance: + :members: + +Functions +--------- + +.. autofunction:: extract_datetime + +.. autofunction:: pdu_to_message + +.. autofunction:: message_to_pdu + +.. autofunction:: sms_to_dict + +.. autofunction:: dict_to_sms + diff --git a/doc/modules/startup.rst b/doc/modules/startup.rst new file mode 100644 index 0000000..0463a42 --- /dev/null +++ b/doc/modules/startup.rst @@ -0,0 +1,28 @@ +:mod:`wader.common.startup` +=========================== + +.. automodule:: wader.common.startup + +Classes +-------- + +.. autoclass:: WaderService + :members: + +.. autoclass:: StartupController + :members: + :show-inheritance: + +Functions +--------- + +.. autofunction:: get_wader_application() + +.. autofunction:: attach_to_serial_port(device) + +.. autofunction:: setup_and_export_device(device) + +.. autofunction:: create_skeleton_and_do_initial_setup() + +.. autofunction:: populate_dbs(f) + diff --git a/doc/modules/utils.rst b/doc/modules/utils.rst new file mode 100644 index 0000000..262bba8 --- /dev/null +++ b/doc/modules/utils.rst @@ -0,0 +1,33 @@ +:mod:`wader.common.utils` +========================= + +.. automodule:: wader.common.utils + +Functions +--------- + +.. autofunction:: rssi_to_percentage(rssi) + +.. autofunction:: convert_ip_to_int(ip) + +.. autofunction:: convert_int_to_ip(i) + +.. autofunction:: convert_int_to_uint(i) + +.. autofunction:: patch_list_signature(props, signature='au') + +.. autofunction:: flatten_list(x) + +.. autofunction:: revert_dict(d) + +.. autofunction:: natsort(l) + +.. autofunction:: get_file_data(path) + +.. autofunction:: save_file(path, data) + +.. autofunction:: is_bogus_ip(ip) + +.. autofunction:: create_dns_lock(dns1, dns2, path) + + diff --git a/doc/user/images/add-contact.png b/doc/user/images/add-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..72536ca7618fea80d0b101e7bc900516ba0ca6c7 GIT binary patch literal 13148 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 literal 0 HcmV?d00001 diff --git a/doc/user/images/delete-contacts.png b/doc/user/images/delete-contacts.png new file mode 100644 index 0000000000000000000000000000000000000000..0d2a19c92e7f338116c4e7c2e2be4f050b512df3 GIT binary patch literal 26802 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 z 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 N)iTnodFuT3e*wW!Wo-Ze literal 0 HcmV?d00001 diff --git a/doc/user/images/delete-sms.png b/doc/user/images/delete-sms.png new file mode 100644 index 0000000000000000000000000000000000000000..c8452821604b94baa1f4a3952da992467778eec1 GIT binary patch literal 27854 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 literal 0 HcmV?d00001 diff --git a/doc/user/images/edit-contact.png b/doc/user/images/edit-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..b1bdd7529ce943601c3458d51e375fae98f11e08 GIT binary patch literal 21665 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%0AX$v*{Zi&g${< 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 literal 0 HcmV?d00001 diff --git a/doc/user/images/new-profile.png b/doc/user/images/new-profile.png new file mode 100644 index 0000000000000000000000000000000000000000..830de7bcc16d8a18d5d8ce12dc717c8be852e58b GIT binary patch literal 45219 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

-MjMsj;!9piX|tVovtZnsaK= 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 literal 0 HcmV?d00001 diff --git a/doc/user/images/select-number.png b/doc/user/images/select-number.png new file mode 100644 index 0000000000000000000000000000000000000000..51bfa3d0bca1820dd8129fc0db1ee64dd0172e96 GIT binary patch literal 13988 zcmdtJbx<5p)Gs(mAP`7`lK=_s?i$>J2N@g&cZUGMCb$IG5L^NT89cZX+zD>M-I+mW zW{2;qeQ&?5eQ)bky+5|LtEaoV>(1@&bI(2ZoZtDK$WN;Budqn5006)%g^w~C0KijS z0N@D-^C{}i`+YhH>h#22LtY918GE;nx_II8QO_L!z$SS7Jpug4ddsgxA(D5Rl z=3_=kP@+E#ysBAf7umjrS1VvD@YB*32CP=L@{(A!^IVP`1?;4!ub5`Kqh4yS42|~* z>XDeJ18xZFg)pU12Q(_wAqW?Bki-N4zEYwA01~*U*8kDWheV}6wdJy?Y@%plJsWYF zVS@(PJCWRrlV~;V8l1RT{XFw9X+sUL*&22JUGJtmO9g87UmW(cf) zlDo;-7xA`s>F9d-%VC)_`0v@8laN3#kMdo=)QzLDupG+wi(JT8vRfxT&h&Sv<^NcM$nY~=eQeqRNTYtMS2Azg>* z@`#fTs+mqmXEcM2o}xOmy;3&EjiH*|cnBAt_gRLRVq}qS8v=<6O^S>DVIzecVar_R zeRahHryWSd@+HTz&+zC<2jyu4}%NEkn<@5 zlVOA;qIQ25`POdZOnjsy5#tyqm#1#*Ii;mhMra>spo%ikD^)${m-1U3hYRo?Har2=;>zxZo-+>NBaS*e`P@ELr)eYXu^ z(ayYwnI|E8Z73Thq%k|OkuIha8Rw96Yle7 zNvq41SA=ncPBLLpyF~V zoPaeABE7`cY)?lCy$wTX$6Yg6vh5?g-bpk$)vld;IJw*YDg}2EO;3;12jfZ!#le%5 z?G8Ct=NOaNS7e^mxm;Iglv}+skY>&yg>6G2KognSf`@V`Nz?mUgtF>{N=FhbMz%}m%_MsBQ?E_ARN(T3 z=i%ZAFR1*XyO;jw)PVh-;_QIg4)jH@--fc( z@OYo38iAl{0-*xB_{4k+wB5gkV93@49<#}a!Q!k3;9irc6fnhdVXpoAHTQ zZ@S`y*pvg#N9ukPF9Sc_T1#3M*uvdOQr~g$3gw3e$rLiW#Z1q=kQ~T(zX!c7E$1Vz z9JgvHi{kw}!kMH2Vdk4kaQyjm$$VE#>F*2QpISGAQ*=vXzQ>d4>re3#^xMw!Ix2FY zS8dZ$=+xBv6TV91)vFiXDkMJCCuNHbp0{Pr0Uc{UU#aB;AV%j#Rrp@W$Sh|ekL|}h zkme8|ku-*~sDEJ=!F5jEW_y0l8vOKvZ|R#Tt9K_;+N$65{`d?&za> z@zW0unj!1R>n>>mkpnGe21qGN!CnM?Y<0S- zG5r@28Nwea-!1FaaiRNTA>k4}9M|F6$(QUt)?~MG(VaC;tlDy1EyB=SD=pssFfWbd zM&4)Jnm%PXSwtdA1HeWXTcZ1uR&3Z4S`6QVk5BR3AQ#OnE^LH`ZFZIgRF`AA6Gz`G zm>PX%hQ3&{+Pn=!1opGd??IQ3zNlCJ0xkb)_&CI6p_gj8ucoiQSBQ+4mYObTBTjPl4q}?6jUU(1-exb+NY4Mktz===XIV|K`(66w)&l5q7@MvI zRwh%J5$~}r3t5AahL7=ve~(yhmjb1M%b&RXG0<@6T z);ruW-bnt)Yy^EcLBCePt(;dc$gwe%>80KY33s|ZUs(=uO;UckKp1{4Scdyo7vWYjY85Zgbp_@;|OvD$-xWo_21=?%xz z&Nw4&AAn_A4oh2a-4+0%yO{%mFpA2UiY!)U^VRw{PL1~PC#O|ZW|KHMJ8_Ns3p^MY zT+}`Pcv4vhn|3DcHZqmy4#nrQTo04J6^CKVBn#iqieNkYo6dB-NTYRET|7hgTJ^SS z&KBl;CIXQk0M_%pZ=A((uIR0R@bz)y=KurCF=0kCrVr1N_ zs5m<6A`%z(sq)^$>o?*z>nE)P=nZ0@L0gE7R{n6~0+*Gq1BIe%9j*%pd|!MS9cLXm zmIXc6Vu2Z&(LwxhSZ}rB$*{F}Lrt%+&3c8}GLPPMi($)`?E-`L?WRMhKK03RwQCCW zhmxE)Y&F}o=jC%^2NDs@MHl#zd}10`Uj)gv=&e1N>i zb5?dX-IjG*$S*e;_19@1oE9h*(qTPOEr~3~e}L@ReYp`Yq28jiv0c;*DzD&vqpF7? z3P0Y1oa#otcy#aW&EWW-_X{=PcsY-kjtPn@a11ji7()BsID@Z_Prq!R+}VcDx7H}i zwv^=;G3+-P`ev!XIbwoC$XF?a92s;gQJ>*b73?HgyMI>-F!Avf{o z(vGJfx9M;48w4>ldrLW>fkxP(9?}T5F4RNFPI0>dxi=Q)2E>etyB?ML7Iq0%R!0`N@N^HGRW4{CKB%EAv(aa=jiDi^j0{YHw9 zma|G*&a97{$3{+JM2$tuwfQEzKbpNE`o&>pMP+0bFW*raw8egEnFU7A*2t}Bo!(5j zeo17&Lcmn!_fP0qj10;amoj9JDz^FUZ+iXgMI)_N zc`D@F>(tcvXCZO)V|pIy1>zkKS3Uf|Gapf>v;te)uoq@7RZPsxLpGd4HRYwHOUK&4 z6!$S9FTr;S2?_ERzxL}sIh(W&8(l#mM;rmLQv7c!Ej#r~^lA$ih|=VE7jp}lz3nwf zW9z!eVdb4P2jrV8PNEVljfn|9g7=(?O)laUgv*{y(s-_~Cyz%(mTXQtq&I`+Wp}48 z@t=U|`<-vmt)(08Ld#|>yC>>!0bF0tH`WU~?4%c0))?|Re8cx}P}&>&@L0+~Jeskl z4fmF3#9UX<7#0QNVjYN^_mXDsQ3~DQAsCO(U}8T7j&u}kT?njTov)JW5r07I6DIDC%pCk=#xgc>(A_JjSFh;zWL^e5M$2VNc;>I)*caiR;tpC=sczEa;s9x*G8 z@SK&!dChWv{MEjX5m~a_1UAS+veOzZpA8%s$+l}sw`|Gw{jeAb9gV%-E~d!R?c#s! z(1U;XW)Qh6^o`vPijR-SwS&Z;l5;ofZ5p&;X5*N#{rtSG3oN$?wyJM#7Ut#si)`vE znwAo6i;Pj2DH7rRqgr$8F!sthHHY7RE_OV5P%7+t9GL~4^xcj*;h&1Gpbd?9`)#7; z3B1=rG(m3SMd4=Qd4g2yJWH>Eu1EQI2YPPqTFSTRrBr6+=S7%;*G#Oni1G)-+z>~T ztIJGBSw4Epj(=P4DgFtrzR^b z{_iBt+nt-l4_5LE?rU2D?=F@FZW~6`UR8O&!X`&T18RaeML$o$L_v47IkbaOrSAKx zUcP?h3fJGPMs7F59N?}=N0-g%4|>|GY}oRiiaDH4L&^Ve#2)AaCa3r1kb|RjwMAc? z;Z+t_S^LCbmVIg-X>1+8HvfMMJ?6Zz(n}rw@D4rCFIC_R&2g2cfW*o+bym&p6p>dE4DlWH5D16ntP6H0P(oiAX(^^N@V~+gEwk0pp}QmhGbq zC;+Ao=h)5kGF_Stjo=F8${Q?4gOrw*%7w|F46n&E4Lg?e z$r3n)3Xh6ebELYu2;V8o^mcDjYq7X1ExjgEEsZ2iCgkM}k+moF>Vq#ZauVkgJgg-d z-$QmpG%VvNvaJSLbP8TRHCHAO_)|z1_>lvbYkkjGYuKHmh$nXJ_kM>{ zYD4wX7~GnTx-Bwc3H!Cwnc^6X8WzgR#L@mYj1e;dk^Y-=d$s*m%58L)ni*l3WuifN z`iy(X(}U-D!?CJMJ3(s>;y;3vc8WDto{C?qAQEqXUCPTge2R~VIETFGUYBMNHoA6P zf>tbTm%EWF%npzj=O%m`USSCsOY$cNCM=6t2dEcA^Lbs9ih9Af{R_p=GT)DZ^scaU zTl)YZKAwn~SGqS1WfdH4p2OBD_z6_Z?u$*>S)e0mM<-3u>Rs+(4Dv(3W|=2@NTG6; zyXOTn^3@xuf$x8Cut9C-Fl1Y%4qF1f-@ltCY>p95{3bPo@d~Y|wjaz@B&g0F>$e~_ z@fkWB?=+2;kPcy}J2?sQeoAY#dUYagljuoJ6n9El{oDwNb)#dQo|(%;vb^yAl0|(ijke>;B)>H|}Z+9DB9ad^%R6=EH_qRlHwle2;g52D{ ztkf4WP&QTyu^KMx?lznqvFp|n=)rx?0SMoGrw>h#clw+iEDKoOA`9GO%Fsk&f04UNS^y9D4|tRlqtrZo`?~Awx0_$EV?h zgQG<0elQaw-rZLBF-_Zh(9H!#|CK{;iI7?cU$N=oK0(pi+1?cyX>489$(ycZpS#iV z%z&#&ERQSUGSHSD)C=KH|woIa7d%;cd5-8wH z217S~IaR>FCQxjq=pF}$igMgkJS`E@!vv~CVJ-Ar)pTDAOsa@$hqDit)^di?=*^PR zuT-0vFm8@5*QgA~DJ!N4-TrM(FyQkF+Z(pFXP!V>CPR&jrJ=4wL6RCos&kLbXZ;T*N>>Y{)BpzIrj7Y5y}nkSabR8ed3 zLAbZnwK$>+cSc##k6zPQKw5v-RXr1Uws~Br0NjYu$$P)y{ZXGc*pabKGxZc5}p> zR<-11f6knE{EEt-wCj;##aqvlE5JJyJwCv#foa<`-Zq1~hqPT3Pe!?_-;KCP_Cy4QFxi?} zMC1+pyc1U{ocTs&+U(M1HRdw8bD48;MS)3n5dK!`B~Lh}I-XlfFHn`Xs!Lt@6N$Zd z%vq!RZdhNf;7()92`H`w%m3c7AXs(!Z$}UcgfTN(qWR!Mt%MsC{J5QqO$Yuyy)pkg zkZ@d9RbWl@p;?|!ly8V}F~3ajx=KYu|K{MSy2Z0A(pt%H)3|}FQqhvlBa}u~|J0); zHwGyEr;ml3C*P;%L)PQtKW%PJc#atfG>2x0k&UVPW&-zW(fZyNcxJz|I@-gox<~ov zfz9%`QsFJygc4pg3vyt>b+@j#57J5_ z5Bj>&VZx#sQ{x$MnucWhHM9L^zAKj?`Mpr%P0>=;Y|VI@k=0rU`m3iC+NTbTHsFL| zYufAFAofbPo{NVyuiXN9%(ke`kDc(5kcOV($Y}XAnvAM6&|v0O9-RXK zj-lg(&;BnE?gU2Gm3_!_ON(PXH2LVVVUfh?krS;DM6@1iqH% zvHtXdys|6T*)uT=N%)>9R6gSgC8kJUthlFYhHKP{eq@nEtrvZG50{BV>D(dF_lYx> z%WqLpiAhNrS%tZ|l+@Hg5i;tm%)*I(JH$O{QDCwJ4!uIn_%4C$_o`X=h|7pA>+a8VGcUS*E3>)L(X4yz*^GoroaFL3k^f@@K$1BJu}> zb~799t5;DV^&UR#^P9LPvx@hxCTHNBdb4)ChCpFX?4@7~MJd%+Z{pn9F{O-fpGw4G zO5Hbq!>}yMJXg%F#GsdqGxoL-^40>Hj9Bm!=(H#h^Vh~N=LGko<*^RW`vF8N4Q*~D$)fAL}7Zecm}w+ zyJ&gzrju>f6agoH*51dg+Gx!M)~gwQ(cj4(NtW!PH+;BR)M)xW$LH21PSI>fP?qsc#pT9xO;sW+siM=k;+Md|> z*Xq^7hOIQW=JS}0maCpNNb}GrB-wxE56yVM)nJAyS$cY606Od+|2YWrLwuS$rTDc( zfRki^Qx~E&`2O-W`2Yt8Q|UN<@Z>2ZC=}D4ZtgseJ|Z6l_wOl4ezX{9 zJ!qdKqwufn^ioc>SP3}P$(w8a?RpJaK4zg9d$?`Km<8StjAmU2dvx7*Jp4XElvJiZ zY$yEa@Vhyzv`3O7W(rPs?ji>6cQ3yoIee!G?0=uY;hPyM6w5bfMWm}u*lR(XtRE71 zt_%DL5N!dzu)ehM3UAmVgI8n8pA@%kKTne(%{62~$Eo8{mm~FHjW!`oK|Wj@Pkjf*=c>B8a`CfB#J!Zl zmnvEg$l4Ar?qG3tiJIiTOiAG|6lbF|WUvcLv|jTz(ts@*;^5(>hpl*AE2Ao~4rQR0 zzJTHwa^$^YiTEY0f|XTBW&<~vr71SEbLMsA% zL3@&iK*q=p%TvfOiuPyxF#r4 zrHUo1^^6V8`Wv>IOml>!`r;y};JIw$Z#_$DtX7R56TP4O0Cn$O2ZZo6we%7!WmADb zI&EK-*vZK<_h_ynyf$(#Sy?>`3y}``5hz>C0H3)Mn(SZ165#_gpFW-K% zgZI+kkIfQszeEf_-LJ=(?X(#EJNqO z9`8|UwfCygqL+|RB9=>dpf#!g+%q0G#FxIRuAcC0==kU$_LRXrqJYBZ5Q*S(8n3}- zuzX1_dR%l}MsLK~z1)OdsnoO)NoL;O9PkjoEfg}k6 z7u-snJ0Z6`sYy8?Ak?$!hvC_8R~aZp44_j9AgP1)tOja|)m91&vBtWoi39eS@jy@J85s?~|EX;`r{*=b(H~L(i71h?mdJ zP1hy;_W31hPs#Mi1TaXSTpLP&gii=Usv;P38q(F;8Kb(jW{|e6uwoOD8H()RMLorH zhv#rRz;PgDh?q>-q%JG5lhe*UajS98wDD-gPHM{IhuBgl*wrL~z{m)Kef@{8xyT;jkCI=VC_)ndo(1Lu`e*1Q2hh6k z@!2thCM}K@3CqhN8&Og+DMTrV_{+8Np~yL8K7Xgs>0Bj+C)w8%vJjLg4g*D{_U!9O zWB%axz9o>YwjYm>3kkgk4~~t&7(mCxq=(t92FRHu!)<1rNX7xPE@HI3cFD#s$7wc} zHc1QCpue{~L}+I&*LmQC2pQ_d27!!-I$NaM- z<@w0qZj~+eHBi-Qyoi%-JhMA1Gb&M>$AOkN^FH%~*~dl`_Ze>@M~kTev@CU+1;?6$ z(t(9+knTEAR@1|0F+Q+MU(W4(tlU49R%s@$M>P^n)9|87{RNdiHTBOT@A1?7Z^uz4 zSW3~*b5(=Cq?NT>pI{K06!sJ}Bi*4~)8HO|-`|L?X$Bl@m^g#(ud$^%mA9@#fN#a= zDNny@?=9DI@Ud7Q#xyVHMEuos{=%>{Xh1G*bu#oLdm?^0rNM21E-6XHr(i6T(`Pam z^oIQ2U${Nfnm)thu6br1@&Hsbp z+^fJygz>M5$oDKGecLoGRYM?c+LBdLliB!Erga=S_x;rEZdRHp^KyoBB|IYAQ1d|L zt=|x6Ckwn^7^rCKSxw7Hy^wloA1Gk^mqceo{Ju_ioQiHieH{QG7JcLbq%BY8G~m>g zXzZ_wW7R*i8Zq=9P2UA^a+fl5RE7m5neozXN2y!L$kcoWhkj2^e!IRzVe%7@>x)i> z7pjUZA344hK9(iR$!dhge#;p>d5=R}@9gui9fYSOz6>-xW?Zua7t->*{`x*=F@@zn={>Wv#Jk2Xp2D#|Ea9stlK z{o;zA7EN3j86Q<&Qj7JpfOw@|+h$s`uJrkTr`Fb1lrQ86Y3VTmeP9;Y}xKJP3F(p zH8j0~YrL9~x6qVP4r=!U2@#OEIzzkM{59lC^bNgfI~AXFo_oyvQkSGiaNRjp3Tth8 z_!|EFFtQb+qY-)6(bl{k-~VB++!qA?JVfc2iNAG?fVrk%e{z* z$YYy?UkDz4xh8twD69O@z3sdxpdCe0q&a^rE7h{qIZnr zxOn1%0iBJMF?K8~wE??hJyZmVb<4Z3#cy+MdM+NlHVWpP+|)%g_LgxybY(KVZxy0) zi3SeUUsiXG9J&lLTMO4}mmHl{HodMYDb>2&omIhLKF)5&ke9ue(R5LcVnU8%{UA9I zaw+Uf6y%EjAgfxrvN)pn#Wfu-cj-CRHEBR2;&aMEx@&!oRH4GH-BTl_onn0_(i{D*LK*4a0U{3F!o$e5b@n#^VqtjlG2 z;3!-`#d=aF8_fFZnlPuaoEF)%+7`>Rsbzo0BRra%!s`Ycps0NMc2SuSXVvLDb!4p3|8`mX7M7)I4 z8((etDxoyNP-N{!=HW1EN$t3jhSYCT+N)h~6P`7;z(cCC)NQkq2L@j(o^u)bpQ?>2 zf;m#JlE|0*Y-_?I8XYfdd|}3SW0%se+MkV*6U+zr9^@}6Iq80JEQO&RP$0h-q~9@U z6c;B9yL+Xf(b39>rOW;S8dZ(|Qsdn7$fu`eS6!&LEaRdXDhk4$6;$>UH-#X9 zotKN*`8w6`A~a5wYlxi;yGo;x+*^4C7#lneDmjs; z&--zfe3sP84qca&sb_H5-xD(Eh)1_?V_yhxf%E7Xx zt86*CG)Nors0{q$5>dJo{x+`TrOF}2ZnD+aJ4eVgf!Fa_fa`3}y}&lmSJ3w5y23tz ze0coUKeLIi3hCk3JBXT<6Pm8KL^=HQ+^pZr{{&)%zWfoNS-W}t*?_Q%a8>+|gZt;R z74AS{V8qA4h~ZoK^r>iOUFtw@VXj>Z@}iKQmo5>h)yqxaPe$92rTI0*ZQjL>F!vta zw6+e9DUya91fQOg^Uo(Wi;gvpd+~wvwj&lDLpVrQP2TPfDm0PT<)fNTz#lIU6G!jf#bfEura?>J`|^(7i!Y zP~ZUueo27A;gy3SR48+n+g>)ZKL4+_`6Ux{FjtYitgWV3UtL4fXdAYs+1(a9#QZBD zISlMJXkS_Ki?LceRV0(&*Ai&W#;nuE?+Zlzw^v_p-DC0i74e`E(5%94e%Ia!wFFtC zQIW{Q*XAl!mR!f915$GbqOn1c58DUT#MGC28I@Q*P7Wcf|BzMsUniS`bGdX>SZ+`A zqd&R3c;7XRCud)e6$`v{SpShDdWgWNVIJ-<5aw3|-3PYr4=c?qj_=BXs=81!+#^_R zAH9^q=0V~=SE45XAJV=hFNT}{?%|Sly^CfDm0GGh`_MhrgZ|CAIV(bc^$-9cZF9D8 zTwWgaedNCxJ$t0Q{Z|%Tu5*_Tr(|4OgEh)~1(1>odea8sR`ovv)M2T{plEUbBc|Nl zQ$N3_J{6g5z5%Oe(zbe$)GQ3zl7Az(xLh@ha}raz8ljpBiL}Gqixjy@X37PVcJ-K2 zU;ADYztg>6IdC6q;iUTgp5pD)UJ2C zjs6B?#h+ooGn3FAOg3AQE^Hs*XO>7AF;5I&2rgxo4aKMp(5;vwyQ8D0!hAx?!X%a% z|NRJjbhWKxcssV_oHY-QWTfJ1D!i%cS+}vobsD)d?aQo{i_7?00dUcP9;V^Ge}B0$ zq{iO3RSNstHOf69!c;>YMeQ1JOQh3&Ih)aY`{LpHf(1f5Cadl+4eYx}-1vSIFpmbv zqNHMKYAtNclAWLlG5$utkl(Q86P&a6Ymy*CTb(cy4}I@leu*tI#rp~~Pk@2SwfP~c z4BnC1IX-FoB==eh1)2HBoq><{@Um~}027+ID5OpBg^+8&pA;Q}-Ea6w_k4(^JrT0& zID)-?aW+>AD!^lKz=ILFS0RAt7xH&I1K=h2AKxaeHF{2CxU=;~6GVXZ0TL*w7RyoL zbzB_S$F^PRh-9|Zq;ZJ4>$)jQUF=P#wWl3xWEj&raI!rzC4G$J;@!vA9@gw17^ydU zg?3}va6oYH$%nayqoIY1jUXB|qw@jh*JDkN$G1J~h_OC0m2Z7$D5B++mX2|4V|w0+ zCwBDnyaQA9q0&GCKRGhj{q7UI z8nka(iI!IVZbbW|-MN*c-yI^k5eKL5%g^f`!;8HyLLZ~?4E*3K<&yX{i?nshH}~I^ z3fh~T4!(D3I^POg4I&X@vu-1p{mQ7%-uvScgX{A-E`ATH0}>Xd^`5;qu%1DfPy8E3 zK>t`Gg*_9W)9_{!114zD_H2a)-kQ%DgK5kiBo_aLiI;zVnBE}$&r!T&RKtzj8=V8u z)eVs0M4=QWae~&*Sz7tu_ji9{GwNtbtUtSQ~5NHTKey-D+qN3Bj;_n zJS-?uOHZzMt*y_eoxt4r_sN0ThM;(qbT3EozlYK^`2ryDZJki@wHo;ZVp~Y73VDC3 z&7tcpiE`cT`@}W8U5*b@u!)TlGlf4AxsjvLd-PkWb2Nk=z;AI_m7k`B=N9Z+KiVxDh^TPiw z9BTWk8*5J?tgW9()XJzwN5y9GU)H5+R`KD6Oo-6Y6_2EQ$<5-}i?cInY!N8asMGZI z_Nt@@C>r_&P1-mde^V?Y``|b7Z{56#2p}BDdTTKlPKjnCr{DK4AX4FVc!8087HQ0+ z31`M=)@=>i*Rc@Fkkt_f{syKVKrT+V(Id=E`CpjC|L=t3E9x7F`d+9Mjrk-dz+Z6~ zhfy}}e?UwAZ=L;jrt*Kg^*<*x|No@f|FUrZS4iLgUzgGU-jAQCwV6PhxYuP-oWT5# z+{pjq8vhR4D@KZ=ZZj^+2JNeuBtXITDA(eD z(<|m)-h5!^efkKo#0@Pr-j49>qz45}5&|Y_hNfs5mR-G2<^h!;3AIo_<%}e-X)7^M z095^K`I1XleyFGY&m~)Vdw@i(zhp-J#qXb96?zSM+gyAMzUrC$EWa@_UxL65PGjXH zGFLaj*S2-SZL44TpM3Q}b=-i=y=^qNo6wyoxDS=#^&g_;MF;RsU3`{Aqq>@0c@M~C zGNeIG@c&vpFwyYOD*dFaru=3C@vgW)4)}#b{2jsf1^g*-IyP$I*GrEGsf3J{rcruM zEsNt>N&Fe3q)8jfw@jJSO4&VHfJ`1pbyHVCpP;a-Ka*%H(}-nJ66yXBS-I;w!mDe{ z9_j*~u-Jv1mi~PYv%_a7Z*0J}>~{#PqCJ6*x7)VNQ}EsV9bh8e#jY+x1IW%vJ$66F zj2-O*pZSEUYLFtMMAcphZ!*49L3w|Bcv9C#`1xw6QKt-vjc@ZpfrZgr6{;BtsbSAi z*)m*M$ZOO7lnVt7E32^SePNG1+~#ytt;ag4sh6tMYPEQ|xxL~aFl0@asouxiKH#ya zV#26o7B21&omOi(wo|%~KuW zCcQHeeAH9nwN-ziL{`3c`7<_$0h33|^(_H{=7lphJX0CV^2|EK!2QkeezaeaiCQCc7yg)F=p_5bo+_<=k;N7Nid0ftNaxv0@%3$t$ML)8FD)4yTU~OA&}N~hr^l=A z?hZ~Y#KkK+tR}fy?1M`>>nX&HS6?OpMGnJ{+20g}$B$9oWs)-~ugetP9f^gytM)#9 z`5#@SeN-cxw;4D&a*#BUQBF-UmF70cUe@%SC*%7JJEH1ae9~T%Bfh9PA{UN=R|bTZ zV4+3f5pF69#)0xNCea`3c>hyk$5Vl(-ERp{Y35t7zLp+yXw1hc-y!DGWkf(0Df!MQ z$EVGEbey+I9wu`z*0S#CycY^q;qs_Q5|{{f<36fP_SwON##8g?>c!JETlI~Qg(AiR z)C}o(`D4P=oIxH9gNe_HN!ZZ`d7fZZkm|ay>-LS*4WCQp)Aahxe8{8no=nludS2~% zTMATw7LLvai$`e8WD@)JkE&;1)#KsO$c?^41qqMO@-Hc6##F{dC8O%!swb(Xzag3H zSOAwYiiM;;s_4~+=fQ~1K)6idq@5m%upQ=yN2$-5zxT3pHL+t}M9m(_`O8FH$43iy zG_4Rp4*&=*oV`VzQ2uiBI$l(#76i+G|EOfB=_`t`gzEApJ-FxH?7_c@v-aAd*Y@=b z_{?@yYELD0JwHsK=J!|0VE0-tEuEBKhQ|ltGdWijDP=Q2%ccbpvpc#B6?zB%$DSE! zS*iK_b=L!z+e8{l?Kr^6!IYftpFS!}l}^5M;3chwK0zt3j11nS>vMNhh_Z*x!eYlO z_eftgZ+Wy5vD_J>1J40pld$Fo|~d9s&zKkI3ZzlQAkon z&D8*#*K$_9E6S3RNW8XtffCP0Utjz_Ow$LQ1Hr0h+L? zXm#8-vY&O7K-u1YvrMm%Ij_-lzTgOQ@R?YQcJUn9M9oB?Q`4pslIqQ4x6ZhCDmmOQ z9I@WNobVK}~_+*{f%0XlMjVin1Tk(9mrjPjVcr z$2TtB4lf@sPu!%HG;tmeFpfpY<1@9poUXgfM>#1?^^a)QPR@>&Y@bb?EFIX~%v~)l zo!o4k-CrjNc%h-aK~s{I()3QIJod*KvsX zY)K)sYup)esYKk)W2Mk8cn;aPMbRv2@%p(#WA{D?hAS3|-7gZWCAwm@V_(>v){(Ip zwo5#qm=jc}!6Psd=AKTP-QTPh=*vmG1U zs73VKo&F5(`P#|N7Id*K-&;`YXmmLuZtlHQ#YqEVNj%p` z(I3HUFncibnmh7=ZDI9A87p3Y#Y}}Apeu4XW|<4oa5^SkLfAnd_+t$kj8s-C5bSwA zFMl2H{7&zJmfpZ?>>zK;n~3prp!a2ytAJ%z<5u3~XKp_awsHd+>r>whV%xt+c}8N! zhq#F$mf4HLzKhf{B0>$Aqp5sbV8FVg5nO<{3s$_7Dy)kL^N`WA)b)L)ETbE*RMU_% z8}{t(ZtKh5x-Nfge7spQL#}P~Os&9EkmTqp;}J{FB@JEFRkjl&pA-L*gb#pYSg(`l zsS$A{itFre2G@EAlLP8lqs}vg)++MFm$_fIW(d>8tIPKv)%SZ%7qu^V+B^8^Vj0qU zbd>Lv;m~9EqG@Nk{U^~Pab|L1*N9In6rMMnUDTWYkk26%tl^4prTzE?l($fnmELobsD5ga40yDL1RJ+lzkRnW z_R2R(>{vin1#g@;R2Ddi?wHI_-kZj>gHi_dXLJ{C|GaBQ1gw?R>w@i}yA~X+Q@#N{B8&5jZea`_PafW^4`RF1$IQy=_8VU@CTmLex(kzF<4&~2J zx6*f0C|!9B8&FI{q(9X4-dLr3$!6aez7I2SwFZ}=#Do(c@}*-6+e$v=fQv-)W| zYBlHxeJinR$lv~90_IQZ1}W8OztL#~M&U#$23AyXuL`@7S#1g_$}F zHd6mf#X937<-R68wfW`Hs~F^0w5%V_mHROX@0f~jRGA2umX&)%U5T3n$vjO6I+aP0 z`~?gE2M3;SmQ}wq43u$SSRq7+PRH}%pL2YL!|}{1(sVVFMz@~`=Yl))9*pD?G|WT1 zq@whsId>qxWP^M&Ai}{<5O&S^XgrT|)J|1|cQl>?S3%3z{LaDA050~x=ank<+F!&- zf1&{9zf)R>Ip@HeNdOMcP@k#ViAWA;eG{elqGpcz zD+I@w(SW4`>Poq8>6}Zz@g{6_!T$KA!*9_;g+9+D3lFt7g*^UW^EMB+pbk2)CU0D? z`%Omey!1Y;%LVD3ITjq!xaYfE<&9!W`%STt8N!j5;-JBgH?Dh=k)N-7NLZoP=whp& zj$YOEyA#jS7R+!T?(a!HX$*@@I*#-z%^7`sDdn!-q~R+^S-wHC>U)!Tt8 z4Ly(e#_PfO9ZS`pcG(Vi%M&ZM^S7ta`O~g2;lriJI~B^f$+i2xOQilJ`6MH)CPH5; zf{2LqYqpnNV`5*b>+8RAX%Ym+#ms$`MC-38bEHJTn3$Mo`S{}I6>COsa_-KDnCZ&a z9phPj1312snr8DrIH0M8-9M3qWJ_5Jt_rIkMWS5qQ3JxwMy4oxGXe2FMhf&BSy+W< zy;7uuL>id=#;Rs3RWn3_Au7;>;*>v+lM9M>Dn2LWs@*BvC_Mxdqv3{6VoB(s>U0(?%Ym^>?AWdUN9sXKzp1w4D|F*`wIjmXLKQB_hv`} z!b1|7W?~|rKObH)|30hC>yQk)LuIht#Ukc$8T?I0n7T5p zVJWG)Ey%WAj8cO1=ZQ0GPYo^TU*&jt#vrj*@o(A5T-e{`tomkUIR{&zgoaEc&*w-t z=DK?&E?u#5A!_<_o2)g~FLD@H;rT+Jb!Uw#J!q&EGH$FB6e<6TijTB}MjWy)Qfm<~ zQpGP?-0{thc^h~r^ENeXSB%2X4_5a@&kmBs7X7vs_&|`4rB<+fr~%)#9>oYde*2^% zmrmysm}S%)R~(7Gw*S+pf-DLCAje;`LN5~fzuP9OPBHHHh(xfP$IRYegQOYW(rY!K zMf^;1uXx6ZVcLYK3FZ23d$yh4vTPCHu8!%U_km=Az5UJXZUK&7``@Oo;eb9%9T!p= z6hnpZ5^!*{jT2uq2^kWJPWF5k17Bys|0gWN=d){?cyTruN|CqZNllr4O;Wni%~=DN z0xNh88a9K2M-OMzVxgld3kIuO$w;6YGKAk!6Y9r%hB=Ey?eM0wNsmSjaQo&l^ynQ2w0xQs zmZ!@zBq!H}{jk*@z|Qqlq$ejF6DsCe`J5=}7*w)J3tUy_!uuMl!vwt)>R`dVbkIv>a_d$XGA2k2Nw~)xDOR zI}(4{0FjXM{!3w^ed{g&`mlMH+GFLqS-jSha%eFrLxlW^ON`imS*I#Q3iqY8)V9>q zv7C*+;BhdWJP7FTs$Sv0N9|(VSBgsqJM~_dv)kRdQj z%_nLy^?b>)Hbo8|J^{%Np}&iBp>H1XFdEOJ^-OV6qR1Sh+vwxH@MAFy0@ypT6#m2`G(~w1)*1RfJpHsJ?-OZ>CK2Kug zUcK@g5$Syz7`n#6aCk~_i=E-H>&_0cs|C*0B&01^gyXQ+r_?@ewjA)N+)6VuNhmQx-EQ_$jYozM&=zXXl3tE~ z*JvhUPibl-7V34@c!_Rohu?dk4|xXeBvN0JQSv6pg-+bR~^7jm|9iL$!McEW4X@_pjS%k_<&xl)l=)xjY;IvOYT z>CS+dj?#NY;A`9$Ty#&Q8KC`LyBdmz2Btut!^-XUX>VlH8-y}jp9&@C5o5y1;KddUF>1k1|vs$A#xp#~iMT%y9oj#;O z<_WxEI5#qT2Ai6$KqF1I)Q&kbOHjx|*6p-xa6)zAA4~5lZ_k|9fU!0=8qI8f1-|9p z-zv}}f=u3a{JeF1(ub*=s|9%==!caRhh@T;F%dA9p{<$fdVfOCc8~WWZoWbdrQvG% zw#a0g|SE$Y>Q45S)05e<3cSmas) zGqfuyY8KWZoPxFd75nDo9@g%ECd=_XV<6|9gPDR$Fa;cuXBWE)+eg@MX^P7wNP`1_r7Ot!?0JH&FWmEhnTh^C4Kq+ z7m30Jf8h*;kKaK9$@=^n*>#PHFWNPgd;2V?HJYy$pPH|Du+XSN81AG^Rph9_(`gz( zJ~IxzOFw;Qgwje8U;0x zfU1dzwY*@+*rp7#72MW9^1Bpll1`yj!=E` ze8$}8-OGa0OEv?Trr*LJWitHszebF-v{?`l79jR_h&VPCK30;i76Z#-P2NrK^1kKq zrNpnqv3y6CAFEkUwQlc|yO(Pg4)i0Rji+JC<@lgPGx6~7wl{*=<-&EZyBhbY#~cTp z*s~ljyp6IQ5jCI^tc>+{z_COK^~ieb8zUn39f9Q33TM5$B}|SSN^=AW`r-#k8dk(R zAZiFIG{`=L$1WncXWrT+Yi6A-GF zLj4vWD*){4NH{Cb2_hkq;hm2&)@h11dozozD+TgQ{3#T`#Xn%VU@(H5NICLO^_{P< zOa9FTGkh^pJ-ma16+p61G6WpMa7+&R{{6c}xAeoO(h@6u`s9t*Oy(jz>a8ELY}tN zp4-|u4(;qqxSlx73bRg_(Ty!Z3VIJPsUao-Vc{HzxN(f1bA+wl{wNhdNU_@I*(@RUHY0DlSVnVzP?_}Oolv% zxv0yonM_^a+V~s7@ajzMNxE>gnv9gA8+yo@D3g$o z!T(i>9o^CK2CLrPe&J2YGi#keU-?NCRw94wlV!xwu;MGQT8fvl?3&~_`={5Q$5Ua? zOI}e?PT+3eNI8B44B@?3Z*l9Ox3IP(rbL_#24BDnczAxVpX~zxo3^L^LwMgb^trTF z3R34j``9&IbV{3vdW&fWyV*W(Vig7q0}{sI>dwNRds{QxKbVVgL59n0K(^6brmgTF zf4q0X2Ns$dM?Jo+OAQgu`wPGrQpsMXhU}u@Y|d>;!mXYZ_czGp(9iMW#>B)>aC<+8 zd~2jMv_`@91DlGH-0my3T5zoYQjR27Jde7V%C#k@q*VU$ zmTuD@cQdWt%QVHRb6Uhx8@oiqXKT2t^K81~a8w4m#rf-nW*sg#Pp9)xM8nh&L`pu@0-a(JJ)t70AQQ`AI^s`!k_&(l~{dB%84^haq>Ok)`6$hj|Bu;tbBxASx!`a3sR#wNREA&=o+Dnw&-U*TE z8q>JYbANO%Oz$~M__%}i)6Q~mhd;Q*%8xLMI8nN!Q&V8g^@r%Co zMwIi4ItN(J>D|xtZsQ@yrBO$Sg-)k?XJ9keZCm<)&w*h)nKUR8ndtJ+c>Bdhx>L)` zlEwRYYRT=;ppfa@)3i+}X2udW$)M26ob~3H>`q1|brE<@LUeI?u=V0nH3hrM&v;MH&F6J)H(7x3Yqnnx^+__#_dBq=^9=g7#TN}cc z;`zL4z9a84`!I9yZRfiSxtx;t$V~Hk9tJnQNU;+JJONU`9P-)58wqhRt{;d$NW!n? z*ZNXZ+u^G5>PgZmQ;|O0U5JB&vdi$nuqTX00=dm}E$WKID#sjv zPVF_dt=Da=dmddEvoia>i6Xe3+l}t2{p`wt+GR-^PO)Lj&Z;{W)dP!~@ILQNx3k%G zve`nortHPYiT}mL@^3%!7N)1AoX82f?deO!pUr2b;9mwV0POYtt6_xGQ2iaS%Mul; zx-%GS&q04$sK<(@IR%jTwd*93prHqCXmbB4xCy>l_iGP&^IUg`r4m`Q4BL))e+fts zkH&lO1_+@}=16rgX4@m*W`jwTH=Fy(Xqvj)k%?c8_b1&)HJWcKE=Iq#w87r`?M%^?qs0%|J zl{b&tiBgorD@3?pa^9Pftf|dE(>m8DL<>dIeu<>#L{N8>UPn=>y!`{Fs{J17PPKsE z2mkPjHFr5CPtQ27+P#gM#zp>D$U**FVBE#Yd+C4~wL78plW?Dl4uRR{w|tFeAGhL# z{Behy2L3FVU$BnN=ewrAtU!8IGE3kyk*v#H7xp;ME{Gw5NOBr#ew+GvIO`=()PriwczWJnhkB&Q;4>R5WVU~(AXfP%BGz#& z{L5#4`%J0mp`r3S0%TWvsqS8fMd(?+-tns^B4K&@KC^p8h2$&X#WL72Xw= z>rlQab@RT9xPgHp=j%6q+X|wtcNvK^s%V|($g|KtqQ1k;*G>w*mVe5LLM}fQIm=1X z@H@Qgd@5pK#2GzYaOn5e*_z9=zwKE~IT_tU)JSenfyyPi||UcO=fC_kgfz%y1-Z8v!~xsJ@_uUJ3ZK916H>^*LV+Z5pri27#-YK1<+G+xJfm0e{O zEex=Jo1vy1tDx(zRx)$LJ(SeQWP=ioHJlWoTmq#|R zSK|f4DEy&Cd-?@o@FREbIK=^oYFt0|4hgkg7AjVav$dt8oGL|T6D^w0*;(I=n z!|FGiDMHJf2ALPZ`<7-97x&@q(Sk2#W@a;0rff?C52;kw2(CfcV`G8DY>$B1?h96S z*K4`q#XEgaQ|GoY3P~h*m#`T?v7z?Emavcd8T*3z6-wP=Ptjwam{R0w{Os>g_K{9WSwn55&`lsy1i6QW$eEh#+b^@ThKlOL%K>ns^#L~y2aQ=$ z>?#T_m->Y&Yw7xhgT`dU&{DF3ytXIhg`#ZEd7b+(t)IP7cz?AW#;oU6WuEHzL{(6T zU=U*8cCx%|ifYBpQ#?H6^-ID&KvhUjYo-TT!Hj3x^tPc}kJHh)viQ4-OI?YA>3fre za=R(RTUSj6uAr}gn6439`0>(@EU{^V;^nUz?D*!ymCx+^N};>zw|4GiW(1gY3<2$G z8NJv(D`D&3D-$iq$%8cDpsIZZji%muRZubVk?o7j)afH&V z!5C%cho%*JHATVCCrPUuHJhUzuP=h9b(?r3KTk{F3xoN|RBJOVJ-CJ2f;LN*n-&y) z0gUT!MGx+}G{x>Gs@`;}Zlyw@mXM>nIu`6Q zwBTnkQgErOK`OWO+oAdRWU#6kejeTeh>s{>XZF1{K;YkexE(e5GiRV%)KQ~f^XTqc*RY{%Wfo{VO}9WRB$H*7bk zgHH$Z`t)AeDdWX9+0sD4C~T?XRkDNM;VxO!#ROD9`5Eha+$@y6ZddxJVk8~=9S(cb zE_hY^S|VN;Ql%}Fuyf(HvL(_6I`47+;*I7Iwz1TC-k*EARxNWxp{ix}>Fm5Q6{@}6dJv;7GT(EDeg3oG(@shYunD>JG ze=J36YA;g8K`38?=8(Nl#gtM4*T$2tLSOqJvq32r>P-f(9lr3{;mU*QtS+!P7R&5O&LFS@{zoh2Ok z(>?7%tAam6yvoI+UT*sWI!(3zxm3&|Xhi1HMV$ZPJLI*N4-n3)@gfJdn@+762Q^8` z?9D+D5fP*bkSh+JT8pk?3o9#_%&@Gi{z1uZ>2krYbGY4tW6HE!FA0ynSH9Ps&?+?q zYp;B$POD%4{YKv$8rt(`|Lg_$R2>qcX{RMQ-TY}jh>r!4!vh3<%=VzONvO==+x4t3 zXBFFcFPqxROGk1!>YPQ&Sd2h25EBZx@U1L116Mem)OQazQ>TPt91UCz42@)S*c=zj z(yNBpBp9Yv{SQ)SzYnHNnIp988~L5})i;M`i=l7!YjC?cFSFsgm97-I`FXzKJ@=-` zfT4(0DOg7ZjemA@Qu zEdIw!s+ei7+#iJelNOSW{P-UELZ&wq64v!~?$D;n9kFKy1z)^pSnn_;$8r%`+K#2M@P!MpL*R znBRUd6F?9?2BKK*AL@AGmil2|O($Me^J(V_ef>VL#q$dR#m9JVbcZGbmbqSy>`)7d+gQ2i%~;0O z`d^3Ja;7s1v2X5hrI#7;JyKaz7{V=ZxcbIPXZOk439!%0ud^68(se_NMkAK*hk?4$ z@Pl}Hgvt8ccVmfRIE3BueFB!+J0_#H7>U>IZuZJdgOHR{*kWHS<;JHm6_NUwT<*MO zRfb%ZwLuI@M0)$gnpGdKm$W3v4=OI+(tQdyCpPU@;HU)Fq$WrVwRc$TlF_^%e{jR< zK<`{Sy;>c;b#wu=j)4}1yzqE9z=7+unMa00BBx6^_bU+oC%{0wgp2E_&4C{RE`5j1nKjik)^5qS5q3&W4v4Vq9XFHC znAFS4;GTQre#B@1cR&YS~k^5d+L&$57?b8jHZ!4GBdj1G}_m$9GvxgFQR_xck|;n~&2{(gk08#&ZJ5HJ9~ z@`^Z&4-pU#$8By5qV;jpdw~|MzVf59^fdUzP1KGXQfa4s!oVqKcy;lfI#VOt&DPfPnLtRV!Afm2N4k@0PuzvYt?A;BhDMTc95#4JT`9s-v#W7D6 z;F27V?S78)cdGE8!O~SaPaW=t$T9>z6x0*Mo;vH^N$#jU8zu~c) zv=oqCnT-=QRC}UXQ>AN`{}u@ySWC_j3=$xWBqg1b<-vHH&0Lp7CU*{q5TI_OyM=t7v z9g68^qT1OWe=17*`%X{|U;V0%zK*q8?fT0pu_OHOW}i^G01i_##_W|kX@hH~(v9u; ztcHx4sF5_n^mlXU_?SDO{^xQ@x#U#cpY=Dq8ctSYZfEXzz=UxY^nGlruZOwJ2h*(9P5 z59Jkz{8Pwv0ww?Ex>lCu)2L|_ElpTY_=~}cXT|?PjE(k$;~$?3B+WS56c>832wV zd-Lm3h{))gQ!XZtXM(8Xe06Jp#^;D8BSwch*8&NmzFu%-oilH%`N+E~a89vb{_tuB zwsC`xeVHE&RJ92X2y}_4Ih9FIFALf2&Rsoa7}>3Au0+l#Q^e`kzE@F)dbHzAIlk`v zuE4-&q_t|o{^|^{xzX_=!w0DDQ5U17*WPRG|NrQ-g&h9jC0cXrVf6-U32Sy z*q7K5h`nfLwV|xi8^$PZ&&*-@5DKSBlqB|tOX!H7DpVK?CKU;Cga#Y->d>*9>Y5{{ z2X9oN^5?uYj)P}kTGmOC=?r1bjuQOmXI~Y9|7WaVeC1&Q@D?)dnUdGGd z!|dj%zOd975@vpx6;hQ1@ufF?j64)r2WLEbzQ^XEIT5Q2e!mYR;xC0P_7DK~6U_}- zl>Ih3Z}LK(egzQ%0lkgjZKMsxSO}N-zv)8%dno>YNJ#%(^uK%Hf9ZjfDvR}RR0fDb znnwT~`6zY#pQ7WxO0x-vn?GuNRXP%n01-Esfb!LO9ow_5V+D0P7i(GB9C?@4p}*6P z8j|oFGXP*#Bst(erORrYuZ}B)jOW;VMHSRiwOUsQy4y`As^+UeVGNpTK^}#LUSR6BviRLoUi!thFT{)+)J6e;D4pG0!PTIX`V1l<1VL8q%%MH6^k{aZfXgIi~H`8#sfI8SQEYmchhl?ok3 z=kLVup82|H6B);<#-7~aaa5jOQGgJRB1_Xoq(j!0ri_HcCHegcVj&aX7B;u>?S9J-BH`*k6UY}_O7UAJD;dQ2iP`i ze!8H=Zr(p`=o8QB<^}zEOopmYcH59PF^t>| z%D|Od`i9F6mOb(_$}cyW;9jscJx%W!;lb8iIE|i5yh2=GrI=c=xboW{T-|?8y4;-0 zwt~jVd!89V4t&RciUqh2N`&{I7kC_#==Pm*u>B$TUaiCP`Ta%8x#Y_&I|KdYYDd=6 z@dRZ0Xmg+T=ns!go6!7mA>b_qt@ePi+U;MaNo@Sx41q?An^|68aref-E%2=tDE^X@ z=qMBG{XQOV8r{Ws$ZLd!a-xLY6s0P?%fuWvbid{RqS0ZWhF zAB2bW&^m9zKqUB889}~|@>x@Sa;j?1mlh$01CY~%dEqHon`~g%JBWZgK z-A)YY$#QtC6!y&1^}Krebaby4>%%2R3ozUK#=E?gnY3mX4*nS(}tp=XLJl%jmtd>#I@ZUh;7l zFPOTz=ERXtBzbKfOiB1d&=&T{&PrO=8Hk>4Wa9 zQC{iSm?&A=1x|m7b1F1ycR*Il&8=KL+q*JL*b?{Q84;x8f~J4v=7T0m z5k&#Ci*8TArPI-uYS7ht_X)l~Q-~FOL#`?frU=2O$SfX;E0(1l+Q~Q#wCal>n9;3p zxbSsQ>N|9G#4>g5p%7Y!4s@z z(!zaIMfbb3)O_e76ypOkW+XmFZVq!KK-sBs|8d4Yau*1!AV~3G6e|DZ1TrE*2k=J%r zUHoFNclmZ#$`v{<=5umqYtZISU`Za%3H^%)$l%OLzVW!M6({7WTCl83L z?}Vf~#W?^=`;dy-pA#~3ygnfff;s|?Y9O7H#R==qptvYOZ%5hh?4pn%ku&GG+HK$Wem9A^1%dQHLMf zI>5o8wamIqi`Pr%m8TNcj|U@M;QjkQ$jE$3WSg+s2 z<~lfFZw`d75bG0WFOabhcPy#bfRh)Wf(QeB9t~qx8u3#tvi+8>_GgdfkURn@IbSb2iQaH+@J*8s&0;Y zpV2x&{E!0gyfH639?+TkJ8U{U5%cGPLI!lR`%*M!JruMnA{swCAfdp+D*WqQ`(R3n zozp}6xj+0nk%+arSw3o$DOIl@<V5*N~&kaX` z10qKTH*_QM<;)b2!3?u7;nN1tLZ#!k4@&`ePmZ<(x3F?5C>V($CAgv^;x8NCxocXx zhvV1%G{&9t1KF6`)3%>7qM>Ko@STvuiq9_^SdKGr^DX=1+mmMJ5*MW6_)5q_Hf%aB zzpSGq$O6s8g>@h>+w#i{(WL>=&g4^_j2GMa5{Z*DyWwDnUUs)2(bn!RL2O8bd;FJ2AQ)X#MjD3qXDct(x}Ula1o&ug;(%E~C!>j;02n%GGe zdgP@G!>>2eT>kTUAw9Is|LDW>;ubD$?)a*-kQ|OSz&<+h%}V%AYTdzwE>77dDmL4f z$Jwj;V>t_d(b>7!fCj^GqNeSFl;+2wG)mXmxw`65+b??6H61wN4FZCVq3(}k zRK^O4bnV+jTiYa;@qm82$F`w(d1yj&!61wHwGX*Wtl>b22t(%iu3&4{($ezb*hYR} z91}4IgAlA2gG|5ZjUj$7cQr?NX{2Zi{GeUB`TNR+eoMnYJmI6NUhli@6bj>&iwn#m z)u&%+wKi_|_dY^Oz9Q@b9sNRW5?4(p*+${K20u6&Xj3oJPM?5NX zAaHs!)vWHIJCMQEvHlHi{gmzO<(}4Zc!p%wP&_J#qVaAsqe-<>zs^_`g*DA=?zSw= zSO^mNfybx({)hHI;;4cJ@6Mx^8V&$BSYo0zR@zLeYeZ#~!`V`0;?qPis4Cv)Fg*?n z1NlV$U8kWt+3sih&CgcT)V$uIy-0_Tk%Bh^?|gyw$>|?Dj*syznf=(+>`+ z=bYFqhF!Ils)g*e>%ojf0KG=J;y1_JZ$aOr+ceBY&1?8kz;?PBRr`j6vd&OxhUW(f8I zIc?u^UG9cID?qf-em!8#NhYhUSU5Qc)FpFC`DZm|`(+*se}TQB*qGNZ0y=JshGgXV znut*OVHZtEq6--fnyy^#uL;EY)?)}gl@a|25jklhF!mi{F!nnZ$PBnK01y-VP!o{C zz7V{=`DRbGs3fcrlKR8^rCrESnY-U6_HEYeUSWELT zKAbA(wYsZcrIImd#{5Ry1^YAvH(w74<7&5UE!2CbDPV84?C#y+-*vvbmd&)C@~2cbUX~y~KgSVR@Ed2h^E=y%N6>_(W=CvWCJng%eKxPA zJ1Vrg#IQbHQJM)?c;^Z@=XKQ4&pe+9rBA&&rhf;%wO|YoE7>?*7%a_KLJ{nRb2!~T zNWO-DUYKI==i67I9Aw}Ug0wBaa-;Q;?GXw2ND%ZL|0AQ|Penn}sBNz2Y^-F{!4M;O zPv;LlFW=9>4y&OGvGCWfZ(tB908CY(GQgAV&F!rk{RD=@jkn0P2#<5q~Zrl zjftD9gJra_-CpHBduT0nDyPSr49&nMm?9r)T3Q6&K!33 zd6dhThMcr2Z7fx|wy}xUsa-XEOI}`WuC?XyyI~h~KkECf*|{lI8noXfVhb7UkO_lf zg|v~AaMBa;>^wUYz7D4x+p3guNIDY`rc3YnnJaVa4O74-7cJQEzTq)(SoP5Xgm(L# z_O7?X?Qc%iCE$M$AiiLBrVw3n)3)=_%<|~neFH)DHT*D&%==^V-NRH#3>EppZY7s@ zS`D-f%LHA>G@sbX8C1!4wJ-Ed@(Im6okf@3YO z^W9x}hrlnz)G3d`Onx^2Qw-(omh-}(GBka=IdkN{*5wyJO`mT(kwNt z{&IrLt|asQfS+6Wz2O&9AYF$O`MQ>S*QVH$M1+^=Td3^gy{u${x-RWq5*>gzv1t0~ z(@HoDE9X9l?AOPuWm^B{<8*yr4vSeRA(z>A1Cz+4h|f%iuC+DkExKPaI^vo=C??jJ zE2u!(ST5&hWLp5bMR$c;I+}(+)?pZo_Kqb%k$#DCu%RkXM^-mL!Ed zJMS=Vru!;Jr-ipp z9Sey`I{rdttw^=P^}3w)?T4POu63qvZ6*Fz;;+v#~1tj3nW9NZAuYP7UXV)x>sq-VL z^UnIyB93Z+)YG<2Y^x=qLu^wrxr6CWf(r3!`vXaApbN`tX(8j~yY?iyisy)Zzs%vm z%e8{913Hw|Ffp6C#v*^ZFN3+pqN>4ikh7jlzPM-Lf*5I#8_)b4t>_@eMiiX1R zmb)YTNC}*^n$0Io;?6G>==LL2#x7<7rf|I-b@5-E9x7ykPY$XMV_NBIL8#Q={;%J_ ze$%~pRU*gVDpFRHI!HxE!P{@tS}Y}bN~pvD*DG<89qC~=2P3~<;A96);hJXtXpS)G z@5=TXIS;-+0}0y&MbV=o~8r1+;*UA$Uc$^Z^uyq?cFIBj41boA+sax3+kJm!BbH{vh8@rMQ0;XgnVF~@2-`D(b_nzoGJfVexmMD&=W zf*xo4*HcFL=Y9twp|+R!<4Dta;>~%ZNxW7-dk3zBZj|dSF&Wcp+-if>p<{{3p{Jeu zyK4o;O5e~D>gONfPQsDFi?3wwuCk$buI&(;LKki!6D`$i3cQO4p*;7_E9_WK0Af-5 zeo-B4`(tD`TUAAA&fs+ddX+ngLCX-wi)y~2JZJDtXDo>Ko4;?Pk#m8j!*Hc7%Qm%W zJP~HjQ91n9>HNie!w(%3*J|`Vfo$2L_TZHAX}kM49Ie&iE_BQJj%x-DZspo9e^9Bf zB9{OM~HIP1sT`q{F9UEnUcdI|Kb_kAC5O>eJV> zmdTFP(NUWfd}xoHtVd#YE_xO`w$Dd4$g2Unt#K&duwl2n^WhG-3v(tN{p+3-~I z$J)=;THO<}A~|-x-lwo*bkgugIo0V<|KrA9Ow6`aFdjq5z5!EF!Drv-7L(j9W=*g% zA%F3Ep{jUXvGg}ZM`;C}TT5u@kL5!jx7BQqFT*M)7~ zy^hP;X(xG?+qF<7+sn~jg?|Q13myko%{J_i5Cr-Sq!|3E7WYb88CT@zWrcW5uElBQ zs@__!?Z$4?cD0^x(O~V*=@@P2&Ul4jQ4x+LXL+Q6_SmhS=SsI}^$#y=ZoXUre2YZp zf3v(U?6!lKFB*-Q@0J?CcHo;<5c@=P;O_W&H8bnyt*nTOhYi)oD6hR#3iGY3 zvqc;ullD|8i@LHas%q`k3{@M}Uy6eFwRhdJiE9#(VwWb%gYv#X9@{8=U7N0}ZIOM> zaSMO$bpr&md)~*Lz<3bycg<3Q@#AaQi~I&>Hg=lJ^+*a0W87f5DGp`rU6KvzuR;Wp zh&Q7@*={TvTzt;mKX=^L88Kr2r3j8uL_l47tw;L*AVyI1-GAwDK6-j9B7C}v(Je%p zf~8iI8DdIORSI?rRTc9E&LWOBOA_IV9A5i;j7bk}{_hn*6@Gp)uYttBS(!gdG9%g( z(`WDZ!%Y4b#`3!?yrt=*$#NlQg$5DP%!~~Gd&RVMI1@L&w3_~VF8058SQkA(nK?94 zw106}P_U7;kQfMu8YYYXK$k|3J&Y9#a(G$wwOI9=3@tVcWBLo01L}r*wzS>d9o+SLA2`T3R;9*`0U~}uLz;a@65J^kEGc>Clc?yKx!o8eDo#mC$e1{b znVj;5q(Vo(P1n`~_$^XDLX_|0)L1u(MM!e+NfiEB_?vKnT`9x-P+=y+)p!z1MG0vwdbw@fmR>DQ_?^+Q(OK5f=Nb6l%rdyyi_5uoz7Pc-y~P z+&;ARh1BobHhK?CH&HptE9U^-8qJ1YTwMUS$%$?;MfqUE3Hy4Y;e|s$Gp1V80U-ec z2kV&z4SCvMUl&h-&ckyUkj)PspSYb1&4)iGm0;;4C!=DZT<#eg%kJKU)^V}t0Htw% zDdKmog_qjvSI}5D7knk9_+DCa`&!bCRvPmX#O#!zj!5c$tS`%*I?^NXc5U4A)+V|C zg~T8cDmpK?WPqft+PN!OkzOsNt`?XuJR5gbJo%6raO{2%iMKwmQ7A46MkRkL&AJeo zRte+F%WoMHgOnN$g~z>wLl$o=@YaedwW*XUQ+{RC>&mNPSrmSf(Zjh{+!?{=k#QGfqI6Y%N1Mu-rGCV_bq zZkROvDk)&lTHN1& z*uQ`(Yb1S-2e2Si2b3rk9aW|e^n^t;Lg9*ks};ZbA1Wjyk*T&mgK*c<>X%7y)fG4U2g+#{Wx}^V)$T`pDe-_AA?s>$m`}E zEnDN-Db!wX_{h6?TY6ybllIZR{(C9v8&nxG$=86G#MdlK-O6xT<~+?uUKk1Yr$t%+ zx{=Fi&IxttKALsy=~wg2L6P|1x?H>JjM>pc@-{B_Lu8~sar`~N?1l+&S__!sRkZ_6 zB!kusuwHb~|R%s7cr2;A2v{cSKLm$5+TwMfv8%E4s+`Se$fL z%EA4`tU-@e($I$Y7avz?#p8!4+851w!9^@ji(l5 z9z7Rm>bI^}stbA5gW86R*YBZe>;m}i((Ovgj&6(+R*~x<37A!9->)f{>6v8rZwZ^g z7yHVZsVy1vFd*{yyvFdDrz zPRt*PF~`EDA3Abn@~bWLhl(Nw#&O?H3^fg`YLI=0*$mqpxJhFeizBNRe04$f5wVC@ z>TXM_r`IMq#sn;GVQ4hgBH5r(Z*J!Jz1!ef*r)k*^meLIM8@Q8JfIJCTQg~~Nxn6L zx^p6Y|J$*!{m=@W011ge@;_SuFQ_(!^xRL98o9}(rC1eyr(71y$Zz)rp@&7~y=ebH z)3p17a|!lwoAF=B&@9)51+9}bm2rN@w)7ElN_VC(*t8nzR>QZMlxP>SIHaa;YHDSP zFhB0yV!t+l5=2I()o@d(|K^{i$XVtNi~G}VSBIHUG?rc}tn&|$eo)w=obH)F|D$M^ zE`83r)l^(V2c@MbkK!$I=l49}PlG74n&>jxJOwdki5BD&{zn--0cY--WB#HLPZJk8-?th1X6Do22GYyRLsp;nS#3Yn z)HIkZL{mK_u35w+9tRxvVm$dCu0#0wXcp@vQj9d+DWQ3sPTEu++#C!eH;D+)!}xhd zv=LgkEg0RW$8I){&gXo=w`6dD-ZKKIW>^k2pS{4 zH>mD9=!1Epm^vPuMGV>*h$ffUJ-!taN6C$dttg@Y`rXm52PI~~&=`9CrzfnP>B~oN zzv&B@Ke7zN-X&)_>*`(dF+ahFrCf{b%FU*x{js5zY-_QcZ@FdjMaqs;GkwOYDlKgY zflEf0!>EqjsdD>`89U40q3KDFm=H%llJreqkP8%~M{$EI{OT)usU;&wZ%zwRM?No5 zDgC%yH)~sH>%mX_L1=_(d{tbt?mTSHE-cc)K{i3d=+t}>*Udr{vBgLxJiECb_D{IB z_lJF5%4p6l#e9{aS~uZgOo<$eACsJpq)^b$vXWo~xgvarf?eM|JEotz>MmIP<5on? zaJ`0IT5N=HG$OC4l)3Z+b5Oy4yrScwNC%?XUK~`(90ug|MWA?XbJVGrkKn{sP zWTOHV46oO@Bk!z_iw2La8^Tf{GTMjE68{{ck$qqPkxTGYMvM|)_Mfyb?YAsF{gcM- zvxm+eQ?+bub=poc=n9NpX7XhA_!3Mjn`|11(YNJX5o*EP(Df-NtILpoj%0MdF`QQ` zqoQ)ZtE20L{Ws(w`Q;sVZ@af4uYxo%jiEg?Ujc~W>#j4VoO+dd5?wW|JvB#dv&+Dy z7-g2Q39M}rj_OuwQ5tR{Z*Ia$dQ#(_LIW> z2P38NU{AwWew|)j)JPaj=IFd6*ACMZ6~Tf;mubzbf833YB}*A6ZyJQ^PBK;hOc+R! zV3cCEuucN1IM6p1x58+c=qI;k5Ig=<+kT@6Y&V`LB)th?A<^T>fen5PBhh=y3=)+-TB^Soh}4AIck(O|F9wYlsY?w11&-tksT_In7~-&qgb38d|B zG|l7anooP@=3%%4rTZCwRUt=Z;%|la-Hh!aR=+3KorA$6Af?I-|6*Z1b-p1c(yr<; z(Y@i-;Lz1$OA~HW4~2cok85kUOGnn7#_zYyG0E8(v8W#{aX-jt8explGVR4v zBs*0iESwmfxVBK|+M}f2`PVc93N-Sk&6@SmZf|c$ zkoa1piNZI%pty@NqoaQNNzPmhmu0#~fSp$QZyfWjyZwL63E0}s;TiRXbqmGhBN%;z zSE)ctzvxF-cQG3Zw)1&zSlxA+FirMSxHG?a&Dy7_3a`;+>uG&5W^Ec*@R(!}n0=ij6RO zL5MqxSM8xtxGhgJqW&ERsH#f}6n$(qrZKWjNRVIsJ({0vFvvsJc z?Yfoe1RsSG4!+1V3i%0!XQ(aFhUj@d6>{O2yx*Fh1f-OvGuyB#SH^PZw902tZg})4 zRyKOb?w(EZ9nSCGFWuQ6FC}udg_m7+t{+XW+G@^Dh(9U)lzKcQpVR3WFUaT>5BvvG zjU8HITnH`XQVBK`c&kra!uW}1CpaI#~gDm&7gAY4t&3^{bmodr@p!z5Sv8vyLfNzopL0x zTTr=C)1LZku8oQXMx>p*#`(&1-gjnO49Kd_SFeK6o~fr{--xycnckTz4ibcXq_b;< zONdL>XRC5%>;;`RZ8u%AAS7wPTctS5?49;m&&^i~0tg}58zpyAp1o`5PcP6Qnj_0h zEN$a>z~u?zROsCOKY1Ab0Ka2t`IRg6E5Q4rTJ8VuC8YwIOnGEVF@?{CglQO1V#$oT|l*41GK~J$Sc@F56sHBMo zwK`$k;G6rei^LeB0M@z@y)*K1zPnf{?tapQvg&Gs%R3=<#fq&pvriVA8uu$Nij>|o zerVtJ2(vR5G87m)m2?)O9v^0uPg-wHc8b3(Qa7(3O|Z1xbMEN0sf;bUK6Pq2WtHlOKyqR zF$$YljBed;PA4~t_{a$3&E5`G_8pHkJikH%gqVl|=Ih=Rb0_c|9DJrvV8j$eM$AOL zbBah?R2vfZ3BsNN``VhkY>3|M#bv3Xu5O=F>T06MX{IW9C2r@*_A}O7%u<&s)hK=b zFzY#O01sl8711263$>w+8TEE)O0Yb*ysy_4Fl#p$`vpDfZ4t87vguEKwh#!G_n!?LgqFn~$x?I%k?eQ53Pl$Uj-I9(brL}w zt6u9#_|u{jKVXo+H*3PhqHG#SaJ& z^!s-Yw+0m-)s+|X%RNrT{dB88cWtCquhQ$1m9&oBY<0*@Ox&zpC%W7}kxz~_#XUAF z#hW5`JqDE5FbU&-LMkvVYgG<5o6$4R+bu7J8Y6x1y9@?!>}DQjc-uEQ&G|xZJBy;P zqXja?)taHf6Q?pV?{nDfFb(WWb3~K#co&8Bo29u>n7w7JUbRHx$DC9YeY0ld(PlZw z*F5?Z9l<59`M1C^mWC^b)|h*2RN3WAR9W|rN6F0jXOC6i_QVAfTtiO$jtN`fYRaLL=s^w}?)fL*ey^^I zlAO8K$jdR-or-#4mNmb+lKK%AyhaA+{N6w1xwN>{F~A}GmWuG-g;xa1RIC*xn7gRO8J z=fZ|>@_&nc4!!nFcO!CHZrzh|atSURwou&NWJFO3g4=S`CN-)?=^a$c4C;NUs_jrM z>eD-@K3^RIm_E~2Yq4l4{dYv2bWtu`dDI_BVQ7H`A;$eb6bjYikN{KGhuEYVoRiLxHENSwD+tvlDM+U_%HBx(oLL zP2cyRyS3$82~Bql1eTLXK|QJ+2S>7Y=gCTeONx=OWnsB)QCdTW9(E=JGc^-Oj(>L1 zR~l< z6LP22zIiwP?M%L6A^oz~&qZx*KXYxD9`1ejS$5vr*aK^~) zao4K#4=YlOS=&u}ugNt*sU4<}MsWD1@bA*didNxRnaR@}7D4b>w+8zs3yTF@9zHY{ z>IR3OHIKqYct%2#T4?D-`K4bLvpl)W0XD-u4chZIgoA4D|IkZ9>5o(Q1I!YY?JVrZcONeb0;xA~? zDdU7ei#$uQbp)54*yY1O!F~k4Hi7FVIXF5akmOx`=^Dw+jjs}x_`gfRNq*Gn8E)(h z^Df5EhJbY3>JXsGa;~707DQFc`AeU+sH^sS3y0L?po!vq%#?2%_>&wxl2JJpL{}nP zVJITf(tnFLE=$N-f{N_za}knHYW01c`x1D&CxtIDPIn~R;75VL*BfD}iA6d`dlrMT zQW5>+V8^8b)r^R34fBt@+n)_NSw{NnwDcRg+z97!w|2L^UzIl5A!fUWJ5T|NDrvIL z2f$t->WH>Zado8FAt?LOJOAMa;-%GXG=?UdH6ZyKZd!xToSCC5#PpL_su~@c+85>< zn0}hZRYx6)0*DuY?8@q+iC0B=_6vEtPiC3``)vgJrEEcl;6gHtr=CCCxKw2cBm*Bblk+qe4fDIbR^l49H5iN84!3hCY{&MCgmO}ahT~Fpp^iurK&SHimOUZv_3MUq83KX!b zPKG1W&+OO|%!M7G>VYum7=Lft;{Lh}Z~X`_. + +In order to use Wader, your user must be member of the ``uucp`` group:: + + sudo /usr/sbin/usermod username -G uucp + +And reboot the machine:: + + sudo reboot + +Installing Wader on Fedora 10 +------------------------------ + +To install Wader on Fedora 10, run as root:: + + wget http://download.opensuse.org/repositories/home:/pablomarti/Fedora_10/home:pablomarti.repo \ + -O /etc/yum.repos.d/home:pablomarti.repo + yum update + yum install wader-gtk + +In order to use Wader, your user must be member of the ``uucp`` group:: + + sudo /usr/sbin/usermod username -G uucp + +and you will also need to disable SELinux while we write a SELinux policy for +Wader. To do so, go to +:menuselection:`System --> Administration --> SELinux Management` and set the +`System Default Enforcing Mode` to Disabled. Check the `Relabel on next +reboot` and reboot the machine:: + + sudo reboot + +Getting started +=============== + +To start the application, go to :menuselection:`Applications --> Internet` +and launch "Mobile Broadband" from there. Alternatively, you can also start +the application from the console with:: + + wader-gtk + +Initial device configuration +---------------------------- + +Once you device is detected its authentication status will be checked and +you may need to enter your PIN number if its enabled. + +.. image:: images/pin-required.png + :alt: Ask PIN dialog + :align: center + +Once the authentication is completed, the device will be initialized and +you will be presented with a window to create a connection profile. In Wader, +you need a connection profile if you want to connect to the Internet. + +Wader itself comes with a small database with tested settings contributed by +users. If your operator happens to be present in that database, you will +only need to select a connection mode (and optionally) a band mode. If your +operator is not present in the database, the profile creation window will +be empty and you will need to fill in the relevant details. If you have +doubts about what you should fill in, you can contact your customer support +service and they will provide you the details. + +.. image:: images/new-profile.png + :alt: New profile window + :align: center + +Connecting to Internet +---------------------- + +Once you have an active connection profile, you are set to connect to Internet. +You just need to press the big "Connect" button in the main window. A small +popup window will appear showing the progress of the connection. Once its +established, the popup will disappear and the big button will read +"Disconnect". + +Press it again and you will be disconnected from Internet. + +Messages +-------- + +Wader provides a CRUD [2]_ interface for managing your SMS. Click on +:menuselection:`View -> Manage SMS/Contacts` to show its window. By default, +it starts on SMS mode. + +Sending a SMS +~~~~~~~~~~~~~ + +Start by clicking on the "New" button or by pressing "Ctrl+N". In the bottom +entry you will type the text to send, and in the middle one the destination +numbers. There is a "To" button to the left of the contacts entry and by +clicking it you will be presented with a list showing all the contacts in +the SIM card. Double-clicking on a contact will add it to the list of +recipients unless is already there. You can of course directly type a number. + +.. image:: images/select-number.png + :alt: Select contact to send SMS to + :align: center + +When you start typing the text, a status bar in the bottom will show you +the number of characters left. There are two main SMS encodings: 7-bit and +UCS2. While the former allows to send texts of up to 140 characters, with +the latter is only 70 characters. + +.. image:: images/send-sms.png + :alt: Sending a SMS + :align: center + +Once your text is ready, you just need to press the "Send" button. + +Receiving SMS +~~~~~~~~~~~~~ + +When a SMS is received, a notification window will be shown displaying the +sender's name (if known), and the SMS text. + +.. image:: images/sms-received.png + :alt: SMS received from Bob + :align: center + +Deleting SMS +~~~~~~~~~~~~ + +Deleting a SMS its as easy as selecting it from the list and clicking on +the delete button. Alternatively you can press the "Del" key or right +clicking on a selection and selecting "Delete" from the popup menu. + +.. image:: images/delete-sms.png + :alt: Deleting a SMS + :align: center + +Contacts +-------- + +Wader provides a CRUD [2]_ interface for managing your contacts. Once you +have started the application, click on the contacts row in the left treeview +to switch to contacts mode. + +.. image:: images/contacts-main.png + :alt: Main contacts window + :align: center + +Adding a contact +~~~~~~~~~~~~~~~~ + +New contacts can be added by clicking on the "New" button or pressing +"Ctrl + N" while in contacts mode. You just need to provide a name, a valid +number and press the "Add" button. + +.. image:: images/add-contact.png + :alt: Adding a contact + :align: center + +Deleting a contact +~~~~~~~~~~~~~~~~~~ + +Contacts can be deleted by clicking on the "Delete" button or pressing the +"Del" key while in contacts mode. You can also select an arbitrary number +of contacts, right click in the selection and clicking on "Delete". + +.. image:: images/delete-contacts.png + :alt: Deleting some contacts + :align: center + +Editing a contact +~~~~~~~~~~~~~~~~~ + +Any contact details can be changed by clicking on its row and directly +start editing its name or number. Once you are done, press "Enter" and that +is it. + +.. image:: images/edit-contact.png + :alt: Editing a contact + :align: center + +Search for contact name +~~~~~~~~~~~~~~~~~~~~~~~ + +You can search for contacts using the search entry in the top left +corner of the contacts application. It will provide suggestions for +what you are typing if it matches with any contact name. + +.. image:: images/search-contacts.png + :alt: Searching for a contact pattern + :align: center + +Troubleshooting +--------------- + +When something goes wrong or does not work, you might have found a bug in +Wader. There are two main log files that will yield clues about what went +wrong: + +/var/log/wader.log + This is wader-core log file and if an AT command has failed, or an + exception has occurred while executing a core process -i.e. network + registration- it will be reflected here. A quick way to access this + file is clicking on "View -> Log". + +/tmp/wader-gtk-username.log + This is wader-gtk log file and if a GUI-related operation has failed, + it will be shown here. + +Armed with this information, you can contact the developers by: + +Asking a question in the `Wader forums `_. + Perhaps your question was already answered here, or perhaps is a new + bug. Either way feel free to contact us with whatever bug, suggestion or + crazy idea you might have about Wader. + +Sending a mail to the `devel list `_: + This is a developer-oriented list, and depending on the question it might + be more appropriate to ask it in the forums. If you want to add a new + device, OS/distro, or a new feature to Wader, we will happily answer + any question you might have. + +.. [1] It has a rather buggy firmware, disable PIN authentication +.. [2] `CRUD `_. + diff --git a/ez_setup.py b/ez_setup.py new file mode 100644 index 0000000..1ec5f99 --- /dev/null +++ b/ez_setup.py @@ -0,0 +1,228 @@ +#!python +"""Bootstrap setuptools installation + +If you want to use setuptools in your package's setup.py, just include this +file in the same directory with it, and add this to the top of your setup.py:: + + from ez_setup import use_setuptools + use_setuptools() + +If you want to require a specific version of setuptools, set a download +mirror, or use an alternate download directory, you can do so by supplying +the appropriate options to ``use_setuptools()``. + +This file can also be run as a script to install or upgrade setuptools. +""" +import sys +DEFAULT_VERSION = "0.6c5" +DEFAULT_URL = "http://cheeseshop.python.org/packages/%s/s/setuptools/" % sys.version[:3] + +md5_data = { + 'setuptools-0.6b1-py2.3.egg': '8822caf901250d848b996b7f25c6e6ca', + 'setuptools-0.6b1-py2.4.egg': 'b79a8a403e4502fbb85ee3f1941735cb', + 'setuptools-0.6b2-py2.3.egg': '5657759d8a6d8fc44070a9d07272d99b', + 'setuptools-0.6b2-py2.4.egg': '4996a8d169d2be661fa32a6e52e4f82a', + 'setuptools-0.6b3-py2.3.egg': 'bb31c0fc7399a63579975cad9f5a0618', + 'setuptools-0.6b3-py2.4.egg': '38a8c6b3d6ecd22247f179f7da669fac', + 'setuptools-0.6b4-py2.3.egg': '62045a24ed4e1ebc77fe039aa4e6f7e5', + 'setuptools-0.6b4-py2.4.egg': '4cb2a185d228dacffb2d17f103b3b1c4', + 'setuptools-0.6c1-py2.3.egg': 'b3f2b5539d65cb7f74ad79127f1a908c', + 'setuptools-0.6c1-py2.4.egg': 'b45adeda0667d2d2ffe14009364f2a4b', + 'setuptools-0.6c2-py2.3.egg': 'f0064bf6aa2b7d0f3ba0b43f20817c27', + 'setuptools-0.6c2-py2.4.egg': '616192eec35f47e8ea16cd6a122b7277', + 'setuptools-0.6c3-py2.3.egg': 'f181fa125dfe85a259c9cd6f1d7b78fa', + 'setuptools-0.6c3-py2.4.egg': 'e0ed74682c998bfb73bf803a50e7b71e', + 'setuptools-0.6c3-py2.5.egg': 'abef16fdd61955514841c7c6bd98965e', + 'setuptools-0.6c4-py2.3.egg': 'b0b9131acab32022bfac7f44c5d7971f', + 'setuptools-0.6c4-py2.4.egg': '2a1f9656d4fbf3c97bf946c0a124e6e2', + 'setuptools-0.6c4-py2.5.egg': '8f5a052e32cdb9c72bcf4b5526f28afc', + 'setuptools-0.6c5-py2.3.egg': 'ee9fd80965da04f2f3e6b3576e9d8167', + 'setuptools-0.6c5-py2.4.egg': 'afe2adf1c01701ee841761f5bcd8aa64', + 'setuptools-0.6c5-py2.5.egg': 'a8d3f61494ccaa8714dfed37bccd3d5d', +} + +import sys, os + +def _validate_md5(egg_name, data): + if egg_name in md5_data: + from md5 import md5 + digest = md5(data).hexdigest() + if digest != md5_data[egg_name]: + print >>sys.stderr, ( + "md5 validation of %s failed! (Possible download problem?)" + % egg_name + ) + sys.exit(2) + return data + + +def use_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, + download_delay=15 +): + """Automatically find/download setuptools and make it available on sys.path + + `version` should be a valid setuptools version number that is available + as an egg for download under the `download_base` URL (which should end with + a '/'). `to_dir` is the directory where setuptools will be downloaded, if + it is not already available. If `download_delay` is specified, it should + be the number of seconds that will be paused before initiating a download, + should one be required. If an older version of setuptools is installed, + this routine will print a message to ``sys.stderr`` and raise SystemExit in + an attempt to abort the calling script. + """ + try: + import setuptools + if setuptools.__version__ == '0.0.1': + print >>sys.stderr, ( + "You have an obsolete version of setuptools installed. Please\n" + "remove it from your system entirely before rerunning this script." + ) + sys.exit(2) + except ImportError: + egg = download_setuptools(version, download_base, to_dir, download_delay) + sys.path.insert(0, egg) + import setuptools; setuptools.bootstrap_install_from = egg + + import pkg_resources + try: + pkg_resources.require("setuptools>="+version) + + except pkg_resources.VersionConflict, e: + # XXX could we install in a subprocess here? + print >>sys.stderr, ( + "The required version of setuptools (>=%s) is not available, and\n" + "can't be installed while this script is running. Please install\n" + " a more recent version first.\n\n(Currently using %r)" + ) % (version, e.args[0]) + sys.exit(2) + +def download_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, + delay = 15 +): + """Download setuptools from a specified location and return its filename + + `version` should be a valid setuptools version number that is available + as an egg for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download attempt. + """ + import urllib2 + egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3]) + url = download_base + egg_name + saveto = os.path.join(to_dir, egg_name) + src = dst = None + if not os.path.exists(saveto): # Avoid repeated downloads + try: + from distutils import log + if delay: + log.warn(""" +--------------------------------------------------------------------------- +This script requires setuptools version %s to run (even to display +help). I will attempt to download it for you (from +%s), but +you may need to enable firewall access for this script first. +I will start the download in %d seconds. + +(Note: if this machine does not have network access, please obtain the file + + %s + +and place it in this directory before rerunning this script.) +---------------------------------------------------------------------------""", + version, download_base, delay, url + ); from time import sleep; sleep(delay) + log.warn("Downloading %s", url) + src = urllib2.urlopen(url) + # Read/write all in one block, so we don't create a corrupt file + # if the download is interrupted. + data = _validate_md5(egg_name, src.read()) + dst = open(saveto,"wb"); dst.write(data) + finally: + if src: src.close() + if dst: dst.close() + return os.path.realpath(saveto) + +def main(argv, version=DEFAULT_VERSION): + """Install or upgrade setuptools and EasyInstall""" + + try: + import setuptools + except ImportError: + egg = None + try: + egg = download_setuptools(version, delay=0) + sys.path.insert(0,egg) + from setuptools.command.easy_install import main + return main(list(argv)+[egg]) # we're done here + finally: + if egg and os.path.exists(egg): + os.unlink(egg) + else: + if setuptools.__version__ == '0.0.1': + # tell the user to uninstall obsolete version + use_setuptools(version) + + req = "setuptools>="+version + import pkg_resources + try: + pkg_resources.require(req) + except pkg_resources.VersionConflict: + try: + from setuptools.command.easy_install import main + except ImportError: + from easy_install import main + main(list(argv)+[download_setuptools(delay=0)]) + sys.exit(0) # try to force an exit + else: + if argv: + from setuptools.command.easy_install import main + main(argv) + else: + print "Setuptools version",version,"or greater has been installed." + print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)' + + + +def update_md5(filenames): + """Update our built-in md5 registry""" + + import re + from md5 import md5 + + for name in filenames: + base = os.path.basename(name) + f = open(name,'rb') + md5_data[base] = md5(f.read()).hexdigest() + f.close() + + data = [" %r: %r,\n" % it for it in md5_data.items()] + data.sort() + repl = "".join(data) + + import inspect + srcfile = inspect.getsourcefile(sys.modules[__name__]) + f = open(srcfile, 'rb'); src = f.read(); f.close() + + match = re.search("\nmd5_data = {\n([^}]+)}", src) + if not match: + print >>sys.stderr, "Internal error!" + sys.exit(2) + + src = src[:match.start(1)] + repl + src[match.end(1):] + f = open(srcfile,'w') + f.write(src) + f.close() + + +if __name__=='__main__': + if len(sys.argv)>2 and sys.argv[1]=='--md5update': + update_md5(sys.argv[2:]) + else: + main(sys.argv[1:]) + + + + + diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/devices/__init__.py b/plugins/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/devices/huawei_e169.py b/plugins/devices/huawei_e169.py new file mode 100644 index 0000000..c69e7a7 --- /dev/null +++ b/plugins/devices/huawei_e169.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE169(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E169""" + name = "Huawei E169" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E169" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1406], + } + +huaweiE169 = HuaweiE169() + diff --git a/plugins/devices/huawei_e17X.py b/plugins/devices/huawei_e17X.py new file mode 100644 index 0000000..223b75f --- /dev/null +++ b/plugins/devices/huawei_e17X.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Author: Jaime Soriano +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from twisted.internet import defer + +from wader.common.hardware.huawei import (HuaweiWCDMADevicePlugin, + HuaweiWCDMACustomizer, + HuaweiWrapper) + +class HuaweiE17XWrapper(HuaweiWrapper): + def get_phonebook_size(self): + # the E170 that we have around keeps raising GenericErrors whenever + # is asked for its size, we'll have to cheat till we have time + # to find a workaround + d = super(HuaweiE17XWrapper, self).get_phonebook_size() + d.addErrback(lambda failure: defer.succeed(250)) + return d + + def get_contacts(self): + # we return a list with all the contacts that match '', i.e. all + return self.find_contacts('') + + +class HuaweiE17XCustomizer(HuaweiWCDMACustomizer): + wrapper_klass = HuaweiE17XWrapper + + +class HuaweiE17X(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E17X""" + name = "Huawei E17X" + version = "0.1" + author = u"Jaime Soriano" + custom = HuaweiE17XCustomizer() + + __remote_name__ = "E17X" + + __properties__ = { + 'usb_device.vendor_id' : [0x12d1], + 'usb_device.product_id' : [0x1003], + } + diff --git a/plugins/devices/huawei_e180.py b/plugins/devices/huawei_e180.py new file mode 100644 index 0000000..05ed10f --- /dev/null +++ b/plugins/devices/huawei_e180.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE180(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E180""" + name = "Huawei E180" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E180" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003], + } + diff --git a/plugins/devices/huawei_e220.py b/plugins/devices/huawei_e220.py new file mode 100644 index 0000000..a4aecd8 --- /dev/null +++ b/plugins/devices/huawei_e220.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import (HuaweiWCDMADevicePlugin, + HuaweiSIMClass) + +class HuaweiE220SIMClass(HuaweiSIMClass): + """Huawei E220 SIM Class""" + def __init__(self, sconn): + super(HuaweiE220SIMClass, self).__init__(sconn) + + def initialize(self, set_encoding=False): + d = super(HuaweiE220SIMClass, self).initialize(set_encoding) + def init_cb(size): + self.sconn.get_smsc() + # before switching to UCS2, we need to get once the SMSC number + # otherwise as soon as we send a SMS, the device would reset + # as if it had been unplugged and replugged to the system + def process_charset(charset): + """ + Do not set charset to UCS2 if is not necessary, returns size + """ + if charset == "UCS2": + return size + else: + d3 = self.sconn.set_charset("UCS2") + d3.addCallback(lambda ignored: size) + return d3 + + d2 = self.sconn.get_charset() + d2.addCallback(process_charset) + return d2 + + d.addCallback(init_cb) + return d + + +class HuaweiE220(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E220""" + name = "Huawei E220" + version = "0.1" + author = u"Pablo Martí" + sim_klass = HuaweiE220SIMClass + + __remote_name__ = "E220" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003, 0x1004], + } + + diff --git a/plugins/devices/huawei_e270.py b/plugins/devices/huawei_e270.py new file mode 100644 index 0000000..c4f7b97 --- /dev/null +++ b/plugins/devices/huawei_e270.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE270(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E270""" + name = "Huawei E270" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E270" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003], + } + diff --git a/plugins/devices/huawei_e272.py b/plugins/devices/huawei_e272.py new file mode 100644 index 0000000..15f7e69 --- /dev/null +++ b/plugins/devices/huawei_e272.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE272(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E272""" + name = "Huawei E272" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E272" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003], + } + diff --git a/plugins/devices/huawei_e620.py b/plugins/devices/huawei_e620.py new file mode 100644 index 0000000..dc75857 --- /dev/null +++ b/plugins/devices/huawei_e620.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import re + +from wader.common.hardware.huawei import (HuaweiWCDMADevicePlugin, + HuaweiCustomizer) +from wader.common.command import build_cmd_dict + +E620_CMD_DICT = HuaweiCustomizer.cmd_dict.copy() +E620_CMD_DICT['get_roaming_ids'] = build_cmd_dict(re.compile( + """ + \r\n + \+CPOL:\s(?P\d+),"(?P\d+)" + """, re.VERBOSE)) + + +class HuaweiE620Customizer(HuaweiCustomizer): + cmd_dict = E620_CMD_DICT + + +class HuaweiE620(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E620""" + name = "Huawei E620" + version = "0.1" + author = u"Pablo Martí" + custom = HuaweiE620Customizer() + + __remote_name__ = "E620" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } + diff --git a/plugins/devices/huawei_e660.py b/plugins/devices/huawei_e660.py new file mode 100644 index 0000000..6a51af0 --- /dev/null +++ b/plugins/devices/huawei_e660.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE660(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E660""" + name = "Huawei E660" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "183" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } diff --git a/plugins/devices/huawei_e660a.py b/plugins/devices/huawei_e660a.py new file mode 100644 index 0000000..d69fd4c --- /dev/null +++ b/plugins/devices/huawei_e660a.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE660A(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E660A""" + name = "Huawei E660A" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E660A" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } diff --git a/plugins/devices/huawei_e870.py b/plugins/devices/huawei_e870.py new file mode 100644 index 0000000..73aff8e --- /dev/null +++ b/plugins/devices/huawei_e870.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiE870(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's E272""" + name = "Huawei E870" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "E870" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003], + } diff --git a/plugins/devices/huawei_em730v.py b/plugins/devices/huawei_em730v.py new file mode 100644 index 0000000..e386d1f --- /dev/null +++ b/plugins/devices/huawei_em730v.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +class HuaweiEM730V(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's EM730V""" + name = "Huawei EM730V" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "EM730V" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } + diff --git a/plugins/devices/huawei_exxx.py b/plugins/devices/huawei_exxx.py new file mode 100644 index 0000000..e1654ff --- /dev/null +++ b/plugins/devices/huawei_exxx.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import HuaweiWCDMADevicePlugin + +from wader.plugins.huawei_e169 import HuaweiE169 +from wader.plugins.huawei_e17X import HuaweiE17X +from wader.plugins.huawei_e180 import HuaweiE180 +from wader.plugins.huawei_e220 import HuaweiE220 +from wader.plugins.huawei_e270 import HuaweiE270 +from wader.plugins.huawei_e272 import HuaweiE272 +from wader.plugins.huawei_e620 import HuaweiE620 +from wader.plugins.huawei_e660 import HuaweiE660 +from wader.plugins.huawei_e660a import HuaweiE660A +from wader.plugins.huawei_e870 import HuaweiE870 +from wader.plugins.huawei_k3520 import HuaweiK3520 +from wader.plugins.huawei_em730v import HuaweiEM730V + + +class HuaweiEXXX1003(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's 1003 family""" + name = "Huawei EXXX" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = None + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1003, 0x1004], + } + + def __init__(self): + super(HuaweiEXXX1003, self).__init__() + + self.mapping = { + 'E17X' : HuaweiE17X, + 'E180' : HuaweiE180, + 'E220' : HuaweiE220, + 'E270' : HuaweiE270, + 'E272' : HuaweiE272, + 'E870' : HuaweiE870, + + 'default' : HuaweiE220, + } + + +class HuaweiEXXX1001(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's 1001 family""" + name = "Huawei EXXX" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = None + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } + + def __init__(self): + super(HuaweiEXXX1001, self).__init__() + + self.mapping = { + 'E169' : HuaweiE169, + 'E660' : HuaweiE660, + 'E660A' : HuaweiE660A, + 'E620' : HuaweiE620, + 'K3520' : HuaweiK3520, + 'EM730V' : HuaweiEM730V, + + 'default' : HuaweiE660, + } + + +huaweiexxx1003 = HuaweiEXXX1003() +huaweiexxx1001 = HuaweiEXXX1001() + diff --git a/plugins/devices/huawei_k3520.py b/plugins/devices/huawei_k3520.py new file mode 100644 index 0000000..012aff3 --- /dev/null +++ b/plugins/devices/huawei_k3520.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.huawei import (HuaweiWCDMADevicePlugin, + HuaweiWrapper, HuaweiWCDMACustomizer) + + +class HuaweiK3520Wrapper(HuaweiWrapper): + def get_contacts(self): + def get_contacts_cb(contacts): + d = self.set_charset("UCS2") + d.addCallback(lambda _: contacts) + return d + + d = self.set_charset("IRA") + d.addCallback(lambda ign: + super(HuaweiK3520Wrapper, self).get_contacts()) + return d + + def find_contacts(self, pattern): + d = self.get_contacts() + d.addCallback(lambda contacts: + [c for c in contacts if c.name.startswith(pattern)]) + return d + + +class HuaweiK3520Customizer(HuaweiWCDMACustomizer): + wrapper_klass = HuaweiK3520Wrapper + + +class HuaweiK3520(HuaweiWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Huawei's K3520""" + name = "Huawei K3520" + version = "0.1" + author = u"Pablo Martí" + custom = HuaweiK3520Customizer() + + __remote_name__ = "K3520" + + __properties__ = { + 'usb_device.vendor_id': [0x12d1], + 'usb_device.product_id': [0x1001], + } diff --git a/plugins/devices/novatel_eu740.py b/plugins/devices/novatel_eu740.py new file mode 100644 index 0000000..8c341d1 --- /dev/null +++ b/plugins/devices/novatel_eu740.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Author: Pablo Martí Gamboa +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelEU740(NovatelWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel's EU740""" + name = "Novatel EU740" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "Expedite EU740" + + __properties__ = { + 'usb_device.vendor_id' : [0x930], + 'usb_device.product_id' : [0x1303], + } + +novateleu740 = NovatelEU740() diff --git a/plugins/devices/novatel_eu870d.py b/plugins/devices/novatel_eu870d.py new file mode 100644 index 0000000..334531d --- /dev/null +++ b/plugins/devices/novatel_eu870d.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelEU870D(NovatelWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Novatel's EU870D MiniCard + """ + name = "Novatel EU870D MiniCard" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "Expedite EU870D MiniCard" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x2420], + } + +novateleu870d = NovatelEU870D() + diff --git a/plugins/devices/novatel_mc990d.py b/plugins/devices/novatel_mc990d.py new file mode 100644 index 0000000..c938028 --- /dev/null +++ b/plugins/devices/novatel_mc990d.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Plugin for Novatel Ovation MC990D +""" + +import serial + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelMC990D(NovatelWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Novatel MC990D + """ + name = "Novatel MC990D" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "Ovation MC990D Card" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x7001], + } + + def preprobe_init(self, ports, info): + # Novatel secondary port needs to be flipped from DM to AT mode + # before it will answer our AT queries. So the primary port + # needs this string first or auto detection of ctrl port fails. + # Note: Early models/firmware were DM only + ser = serial.Serial(ports[0], timeout=1) + ser.write('AT$NWDMAT=1\r\n') + ser.close() + + +novatelmc990d = NovatelMC990D() + diff --git a/plugins/devices/novatel_ovation.py b/plugins/devices/novatel_ovation.py new file mode 100644 index 0000000..c5d95c7 --- /dev/null +++ b/plugins/devices/novatel_ovation.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Plugin for Novatel Ovation +""" + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelOvation(NovatelWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel's Ovation""" + name = "Novatel MC950D" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "Ovation MC950D Card" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x4400], + } + +novatelovation = NovatelOvation() diff --git a/plugins/devices/novatel_s720.py b/plugins/devices/novatel_s720.py new file mode 100644 index 0000000..831076b --- /dev/null +++ b/plugins/devices/novatel_s720.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008 Warp Networks S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +# Plugin temporally disabled while we sort out the CDMA part + +#class NovatelS720(NovatelCDMADevicePlugin): +class NovatelS720(object): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel S720""" + name = "Novatel S720" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "MERLIN S720" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x1130], + } + diff --git a/plugins/devices/novatel_u630.py b/plugins/devices/novatel_u630.py new file mode 100644 index 0000000..6f18949 --- /dev/null +++ b/plugins/devices/novatel_u630.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelU630(NovatelWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel's U630""" + name = "Novatel U630" + version = "0.1" + author = u"Pablo Martí" + + __properties__ = { + 'pcmcia.manf_id': [0xa4], + 'pcmcia.card_id': [0x276], + } + +novatelu630 = NovatelU630() diff --git a/plugins/devices/novatel_u740.py b/plugins/devices/novatel_u740.py new file mode 100644 index 0000000..7f86560 --- /dev/null +++ b/plugins/devices/novatel_u740.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Author: Adam King - heavily based on Pablo Marti's U630 plugin +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelU740(NovatelWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel's U740""" + name = "Novatel U740" + version = "0.1" + author = "Adam King" + + __remote_name__ = "Merlin U740 (HW REV [0:33])" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x1400, 0x1410] + } + +novatelu740 = NovatelU740() diff --git a/plugins/devices/novatel_xu870.py b/plugins/devices/novatel_xu870.py new file mode 100644 index 0000000..911d822 --- /dev/null +++ b/plugins/devices/novatel_xu870.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.novatel import NovatelWCDMADevicePlugin + +class NovatelXU870(NovatelWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Novatel's XU870""" + name = "Novatel XU870" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "Merlin XU870 ExpressCard" + + __properties__ = { + 'usb_device.vendor_id' : [0x1410], + 'usb_device.product_id' : [0x1430], + } + +novatelxu870 = NovatelXU870() diff --git a/plugins/devices/option_colt.py b/plugins/devices/option_colt.py new file mode 100644 index 0000000..e6b6b00 --- /dev/null +++ b/plugins/devices/option_colt.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +DevicePlugin for Option Colt + +(end of life reached) +""" + +from twisted.python import log + +from wader.common.hardware.option import (OptionWCDMADevicePlugin, + OptionWCDMACustomizer) +from wader.common.sim import SIMBaseClass +from wader.common.statem.auth import AuthStateMachine +from epsilon.modal import mode + + +class OptionColtAuthStateMachine(AuthStateMachine): + """ + Custom AuthStateMachine for Option Colt + + This device has a rather buggy firmware that yields all sort of + weird errors. For example, if PIN authentication is disabled on the SIM + and you issue an AT+CPIN? command, it will reply with a +CPIN: SIM PUK2 + """ + pin_needed_status = AuthStateMachine.pin_needed_status + puk_needed_status = AuthStateMachine.puk_needed_status + puk2_needed_status = AuthStateMachine.puk2_needed_status + + class get_pin_status(mode): + """ + Returns the authentication status + + The SIM can be in one of the following states: + + - SIM is ready (already authenticated, or PIN disabled) + - PIN is needed + - PIN2 is needed (not handled) + - PUK is needed + - PUK2 is needed + - SIM is not inserted + - SIM's firmware error + """ + def __enter__(self): + pass + def __exit__(self): + pass + + def do_next(self): + log.msg("Instantiating get_pin_status mode....") + d = self.device.sconn.get_pin_status() + d.addCallback(self.get_pin_status_cb) + d.addErrback(self.sim_failure_eb) + d.addErrback(self.sim_busy_eb) + d.addErrback(self.sim_no_present_eb) + d.addErrback(log.err) + + +class OptionColtSIMClass(SIMBaseClass): + """Option Colt SIM Class""" + def __init__(self, sconn): + super(OptionColtSIMClass, self).__init__(sconn) + + def initialize(self, set_encoding=False): + self.charset = 'IRA' + d = super(OptionColtSIMClass, self).initialize(set_encoding) + d.addCallback(lambda size: self.set_size(size)) + return d + +class OptionColtCustomizer(OptionWCDMACustomizer): + """:class:`~wader.common.hardware.Customizer` for Option Colt""" + auth_klass = OptionColtAuthStateMachine + + +class OptionColt(OptionWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Option Colt""" + name = "Option Colt" + version = "0.1" + author = u"Pablo Martí" + custom = OptionColtCustomizer() + sim_klass = OptionColtSIMClass + + __remote_name__ = "129" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id' : [0x5000], + } + + +optioncolt = OptionColt() + diff --git a/plugins/devices/option_etna.py b/plugins/devices/option_etna.py new file mode 100644 index 0000000..e3ef2c0 --- /dev/null +++ b/plugins/devices/option_etna.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.option import (OptionWCDMADevicePlugin, + OptionWCDMACustomizer, + OptionWrapper) + + +class OptionEtnaWrapper(OptionWrapper): + def get_roaming_ids(self): + # FW 2.8.0Hd while panik if AT+CPOL is sent while in UCS2, we will + # switch to IRA, perform the operation and switch back to UCS2 + self.set_charset("IRA") + d = super(OptionEtnaWrapper, self).get_roaming_ids() + def get_roaming_ids_cb(rids): + d2 = self.set_charset("UCS2") + d2.addCallback(lambda _: rids) + return d2 + + d.addCallback(get_roaming_ids_cb) + return d + + def find_contacts(self, pattern): + """Returns a list of `Contact` whose name matches pattern""" + # ETNA's AT+CPBF function is broken, it always raises a + # CME ERROR: Not Found (at least with the following firmware rev: + # FW 2.8.0Hd (Date: Oct 11 2007, Time: 10:20:29)) + # we have no option but to use this little hack and emulate AT+CPBF + # getting all contacts and returning those whose name match pattern + # this will be slower than AT+CPBF with many contacts but at least + # works + d = self.get_contacts() + d.addCallback(lambda contacts: + [c for c in contacts if c.name.startswith(pattern)]) + return d + + +class OptionEtnaCustomizer(OptionWCDMACustomizer): + wrapper_klass = OptionEtnaWrapper + + +class OptionEtna(OptionWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Options's Etna""" + name = "Option Etna" + version = "0.1" + author = u"Pablo Martí" + custom = OptionEtnaCustomizer() + + __remote_name__ = "GlobeTrotter HSUPA Modem" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x7001], + } + +optionetna = OptionEtna() + diff --git a/plugins/devices/option_globesurfericon.py b/plugins/devices/option_globesurfericon.py new file mode 100644 index 0000000..dfad936 --- /dev/null +++ b/plugins/devices/option_globesurfericon.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Simone Tolotti +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +class OptionGlobesurferIcon(OptionWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Options's Globesurfer Icon + """ + name = "Option Globesurfer Icon" + version = "0.1" + author = "Simone Tolotti" + + __remote_name__ = "GlobeSurfer ICON" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6600], + } + +optionglobesurfericon = OptionGlobesurferIcon() diff --git a/plugins/devices/option_gtfusion.py b/plugins/devices/option_gtfusion.py new file mode 100644 index 0000000..3497f7f --- /dev/null +++ b/plugins/devices/option_gtfusion.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +# Stefano Rivera contributed this info through email on 20 Sept 2007 + +class OptionGTFusion(OptionWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Option's GT Fusion + """ + name = "Option GlobeTrotter Fusion" + version = "0.1" + author = "Stefano Rivera" + + __remote_name__ = 'GlobeTrotter Fusion' + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6000], + } + +optiongtfusion = OptionGTFusion() diff --git a/plugins/devices/option_gtfusionquadlite.py b/plugins/devices/option_gtfusionquadlite.py new file mode 100644 index 0000000..34e1a1a --- /dev/null +++ b/plugins/devices/option_gtfusionquadlite.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Stefano Rivera +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +class OptionGTFusionQuadLite(OptionWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Option's GT Fusion Quad Lite + """ + name = "Option GT Fusion Quad Lite" + version = "0.1" + author = "Stefano Rivera" + + __remote_name__ = 'GlobeTrotter Fusion Quad Lite' + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6300], + } + +optiongtfusionquadlite = OptionGTFusionQuadLite() diff --git a/plugins/devices/option_gtm378.py b/plugins/devices/option_gtm378.py new file mode 100644 index 0000000..65213d4 --- /dev/null +++ b/plugins/devices/option_gtm378.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Jaime Soriano +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +# Ulf Michel contributed this info: +# http://forge.vodafonebetavine.net/forum/message.php?msg_id=630 +# +# OptionGTM378 integrated in Fuijitsu-Siemens Esprimo Mobile U Series + +class OptionGTM378(OptionWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for Option's GTM378 + """ + name = "Option GT M378" + version = "0.1" + author = "Ulf Michel" + dialer = 'hso' + + __remote_name__ = 'GTM378' + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6901, 0x6911], + } + +optiongtm378 = OptionGTM378() diff --git a/plugins/devices/option_gtmax36.py b/plugins/devices/option_gtmax36.py new file mode 100644 index 0000000..d5e849c --- /dev/null +++ b/plugins/devices/option_gtmax36.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: kgb0y +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +class OptionGTMax36(OptionWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Option's GT Max 36""" + name = "Option GT Max 36" + version = "0.1" + author = "kgb0y" + + __remote_name__ = "GlobeTrotter HSDPA Modem" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6701], + } + +optiongtmax36 = OptionGTMax36() diff --git a/plugins/devices/option_icon225.py b/plugins/devices/option_icon225.py new file mode 100644 index 0000000..bacba36 --- /dev/null +++ b/plugins/devices/option_icon225.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Isaac Clerencia +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionHSOWCDMADevicePlugin + +class OptionIcon225(OptionHSOWCDMADevicePlugin): + """:class:`wader.common.plugin.DevicePlugin` for Options's Icon 225""" + name = "Option Icon 225" + version = "0.1" + author = "Isaac Clerencia" + + dialer = 'hso' + + __remote_name__ = "GlobeTrotter HSDPA Modem" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x6971], + } + +optionicon225 = OptionIcon225() diff --git a/plugins/devices/option_icon401.py b/plugins/devices/option_icon401.py new file mode 100644 index 0000000..c524698 --- /dev/null +++ b/plugins/devices/option_icon401.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Marti +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionHSOWCDMADevicePlugin + +class OptionIcon401(OptionHSOWCDMADevicePlugin): + """:class:`wader.common.plugin.DevicePlugin` for Options's Icon 401""" + name = "Option Icon 401" + version = "0.1" + author = "Pablo Marti" + + dialer = 'hso' + + __remote_name__ = "GlobeTrotter HSUPA Modem" + + __properties__ = { + 'usb_device.vendor_id' : [0x0af0], + 'usb_device.product_id': [0x7401], + } + +optionicon401 = OptionIcon401() diff --git a/plugins/devices/option_k3760.py b/plugins/devices/option_k3760.py new file mode 100644 index 0000000..022c30f --- /dev/null +++ b/plugins/devices/option_k3760.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import (OptionHSOWCDMADevicePlugin, + OptionHSOWCDMACustomizer, + OptionWrapper) + +class OptionK3760Wrapper(OptionWrapper): + + def find_contacts(self, pattern): + d = self.get_contacts() + d.addCallback(lambda contacts: + [c for c in contacts if c.name.startswith(pattern)]) + return d + + +class OptionK3760Customizer(OptionHSOWCDMACustomizer): + wrapper_klass = OptionK3760Wrapper + + +class OptionK3760(OptionHSOWCDMADevicePlugin): + """:class:`wader.common.plugin.DevicePlugin` for Options's K3760""" + name = "Option K3760" + version = "0.1" + author = u"Pablo Martí" + custom = OptionK3760Customizer() + + dialer = 'hso' + + __remote_name__ = "GlobeTrotter HSUPA Modem" + + __properties__ = { + 'usb_device.vendor_id' : [0xaf0], + 'usb_device.product_id': [0x7501], + } + +option_k3760 = OptionK3760() + diff --git a/plugins/devices/option_nozomi.py b/plugins/devices/option_nozomi.py new file mode 100644 index 0000000..3d72390 --- /dev/null +++ b/plugins/devices/option_nozomi.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.option import OptionWCDMADevicePlugin + +class OptionNozomi(OptionWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Option's Nozomi""" + name = "Option GlobeTrotter 3G+" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "GlobeTrotter 3G+" + + __properties__ = { + 'pci.vendor_id' : [0x1931], + 'pci.product_id' : [0xc], + } + +option_nozomi = OptionNozomi() diff --git a/plugins/devices/sierrawireless_850.py b/plugins/devices/sierrawireless_850.py new file mode 100644 index 0000000..9be2331 --- /dev/null +++ b/plugins/devices/sierrawireless_850.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +DevicePlugin for the Sierra Wireless 850 datacard +""" + + +from wader.common.hardware.sierra import SierraWCDMADevicePlugin + +class SierraWireless850(SierraWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for SierraWireless 850""" + name = "SierraWireless 850" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "AC850" #response from AT+CGMM + + __properties__ = { + 'pcmcia.manf_id' : [0x192], + 'pcmcia.card_id': [0x710], + } + +sierrawireless850 = SierraWireless850() diff --git a/plugins/devices/sierrawireless_875.py b/plugins/devices/sierrawireless_875.py new file mode 100644 index 0000000..58c0b3f --- /dev/null +++ b/plugins/devices/sierrawireless_875.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +DevicePlugin for the Sierra Wireless 875 datacard +""" + +from wader.common.hardware.sierra import SierraWCDMADevicePlugin + +class SierraWireless875(SierraWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for SierraWireless 875""" + name = "SierraWireless 875" + version = "0.1" + author = "anmsid, kgb0y" + + __remote_name__ = "AC875" #response from AT+CGMM + + __properties__ = { + 'usb_device.vendor_id' : [0x1199], + 'usb_device.product_id': [0x6820], + } + +sierrawireless875 = SierraWireless875() diff --git a/plugins/devices/sonyericsson_k610i.py b/plugins/devices/sonyericsson_k610i.py new file mode 100644 index 0000000..e766796 --- /dev/null +++ b/plugins/devices/sonyericsson_k610i.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.sonyericsson import SonyEricssonCustomizer +from wader.common.plugin import DevicePlugin +from wader.common.middleware import WCDMAWrapper + +class K610iWrapper(WCDMAWrapper): + def set_charset(self, charset): + if charset == 'UCS2': + d = super(K610iWrapper, self).set_charset('IRA') + else: + d = super(K610iWrapper, self).set_charset(charset) + + d.addCallback(lambda ignord: self.device.sim.set_charset(charset)) + return d + +class SonyEricssonK610iCustomizer(SonyEricssonCustomizer): + wrapper_klass = K610iWrapper + +class SonyEricssonK610iUSB(DevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for Sony Ericsson k610i""" + name = "Sony Ericsson K610i" + version = "0.1" + author = u"Jaime Soriano" + custom = SonyEricssonK610iCustomizer() + + __remote_name__ = "AAD-3022041-BV" + + __properties__ = { + 'usb_device.vendor_id': [0x0fce], + 'usb_device.product_id': [0xd046], + } + +sonyericsson_k610iUSB = SonyEricssonK610iUSB() diff --git a/plugins/devices/sonyericsson_k618i.py b/plugins/devices/sonyericsson_k618i.py new file mode 100644 index 0000000..33f87d7 --- /dev/null +++ b/plugins/devices/sonyericsson_k618i.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from wader.common.hardware.sonyericsson import SonyEricssonCustomizer +from wader.common.plugin import RemoteDevicePlugin + +class SonyEricssonK618i(RemoteDevicePlugin): + """ + :class:`~wader.common.plugin.RemoteDevicePlugin` for SonyEricsson's K618i + """ + name = "SonyEricsson K618i" + version = "0.1" + author = u"Pablo Martí" + custom = SonyEricssonCustomizer + + __remote_name__ = "AAD-3022042-BV" + +sonyericsson_k618i = SonyEricssonK618i() diff --git a/plugins/devices/zte_k3520.py b/plugins/devices/zte_k3520.py new file mode 100644 index 0000000..46f081f --- /dev/null +++ b/plugins/devices/zte_k3520.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2007 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Andrew Bird +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.zte import ZTEWCDMADevicePlugin + +class ZTEK3520(ZTEWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for ZTE's K3520""" + name = "Vodafone K3520-Z" + version = "0.1" + author = "Andrew Bird" + + __remote_name__ = "K3520-Z" + + __properties__ = { + 'usb_device.vendor_id': [0x19d2], + 'usb_device.product_id': [0x0025, 0x0055], # depends on fw rev + } + + harcoded_ports = (1, 3) + +zte_k3520 = ZTEK3520() + diff --git a/plugins/devices/zte_k3565.py b/plugins/devices/zte_k3565.py new file mode 100644 index 0000000..edc5f77 --- /dev/null +++ b/plugins/devices/zte_k3565.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2007 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +__version__ = "$Rev: 1209 $" + +from wader.common.hardware.zte import ZTEWCDMADevicePlugin + +class ZTEK3565(ZTEWCDMADevicePlugin): + """ + :class:`~wader.common.plugin.DevicePlugin` for ZTE K3565 + """ + name = "Vodafone K3565-Z" + version = "0.1" + author = "Andrew Bird" + + __remote_name__ = "K3565-Z" + + __properties__ = { + 'usb_device.vendor_id': [0x19d2], + 'usb_device.product_id': [0x0049, 0x0052], # depends on firmware version + } + + hardcoded_ports = (2, 1) + +zte_k3565 = ZTEK3565() + diff --git a/plugins/devices/zte_mf620.py b/plugins/devices/zte_mf620.py new file mode 100644 index 0000000..611843b --- /dev/null +++ b/plugins/devices/zte_mf620.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2007 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Zhao Ming +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.zte import ZTEWCDMADevicePlugin + +class ZTEMF620(ZTEWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for ZTE's MF620""" + name = "ZTE MF620" + version = "0.1" + author = "Zhao Ming" + + __remote_name__ = "MF620" + + __properties__ = { + 'usb_device.vendor_id': [0x19d2], + 'usb_device.product_id': [0x0001], + } + diff --git a/plugins/devices/zte_mf632.py b/plugins/devices/zte_mf632.py new file mode 100644 index 0000000..05b1aca --- /dev/null +++ b/plugins/devices/zte_mf632.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.zte import ZTEWCDMADevicePlugin + +class ZTEMF632(ZTEWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for ZTE's MF632""" + name = "ZTE MF632" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = "MF632" + + __properties__ = { + 'usb_device.vendor_id': [0x19d2], + 'usb_device.product_id': [0x0002], + } + diff --git a/plugins/devices/zte_mf6xx.py b/plugins/devices/zte_mf6xx.py new file mode 100644 index 0000000..0c760b9 --- /dev/null +++ b/plugins/devices/zte_mf6xx.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.hardware.zte import ZTEWCDMADevicePlugin + +from wader.plugins.zte_mf620 import ZTEMF620 +from wader.plugins.zte_mf632 import ZTEMF632 + +class ZTEMF6XX(ZTEWCDMADevicePlugin): + """:class:`~wader.common.plugin.DevicePlugin` for ZTE's MF6XX Family""" + name = "ZTE MF6XX" + version = "0.1" + author = u"Pablo Martí" + + __remote_name__ = None + + __properties__ = { + 'usb_device.vendor_id': [0x19d2], + 'usb_device.product_id': [0x0001], + } + + def __init__(self): + self.mapping = { + 'MF620' : ZTEMF620, + 'MF632' : ZTEMF632, + + 'default' : ZTEMF620, + } + +zte_mf6xx = ZTEMF6XX() + diff --git a/plugins/oses/__init__.py b/plugins/oses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/oses/debian.py b/plugins/oses/debian.py new file mode 100644 index 0000000..c9e1acc --- /dev/null +++ b/plugins/oses/debian.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Debian OSPlugin +""" +from os.path import exists + +from wader.common.oses.linux import LinuxPlugin + +class DebianBasedDistro(LinuxPlugin): + def is_valid(self): + if exists('/etc/debian_version'): + # do not recognize Ubuntu as a DebianBasedDistro (is true tho) + return not exists('/etc/lsb-release') + + return False + +debian = DebianBasedDistro() diff --git a/plugins/oses/fedora.py b/plugins/oses/fedora.py new file mode 100644 index 0000000..24bad01 --- /dev/null +++ b/plugins/oses/fedora.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Fedora OSPlugin""" + +from os.path import exists +import re + +from wader.common.oses.linux import LinuxPlugin +from wader.common.utils import get_file_data + +class FedoraBasedDistro(LinuxPlugin): + """ + OSPlugin for Fedora-based distros + """ + + #XXX: Almost duplicated code with Suse plugin + def get_timezone(self): + timezone_re = re.compile('ZONE="(?P[\w/]+)"') + sysconf_clock_file = get_file_data('/etc/sysconfig/clock') + search_dict = timezone_re.search(sysconf_clock_file).groupdict() + return search_dict['tzname'] + + def is_valid(self): + paths = ['/etc/redhat-release', '/etc/fedora-release'] + return any(map(exists, paths)) + +fedora = FedoraBasedDistro() diff --git a/plugins/oses/freebsd.py b/plugins/oses/freebsd.py new file mode 100644 index 0000000..d674a27 --- /dev/null +++ b/plugins/oses/freebsd.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +FreeBSD-based OS plugin +""" + +from wader.common.oses.bsd import FreeBSDPlugin + +class FreeBSD7Plugin(FreeBSDPlugin): + """Plugin for FreeBSD 7""" + +freebsd7plugin = FreeBSD7Plugin() diff --git a/plugins/oses/osx.py b/plugins/oses/osx.py new file mode 100644 index 0000000..4f18e94 --- /dev/null +++ b/plugins/oses/osx.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +OSX plugin +""" +from wader.common.oses.osx import OSXPlugin + +osxplugin = OSXPlugin() diff --git a/plugins/oses/suse.py b/plugins/oses/suse.py new file mode 100644 index 0000000..857c12c --- /dev/null +++ b/plugins/oses/suse.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""SLED OSPlugin""" + +from os.path import exists +import re + +from wader.common.oses.linux import LinuxPlugin +from wader.common.utils import get_file_data + +class SUSEDistro(LinuxPlugin): + + #XXX: Almost duplicated code with Fedora plugin + def get_timezone(self): + timezone_re = re.compile('TIMEZONE="(?P[\w/]+)"') + sysconf_clock_file = get_file_data('/etc/sysconfig/clock') + search_dict = timezone_re.search(sysconf_clock_file).groupdict() + return search_dict['tzname'] + + def is_valid(self): + return exists('/etc/SuSE-release') + +susedistro = SUSEDistro() diff --git a/plugins/oses/ubuntu.py b/plugins/oses/ubuntu.py new file mode 100644 index 0000000..51dd417 --- /dev/null +++ b/plugins/oses/ubuntu.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Ubuntu OSPlugin""" + +from os.path import exists +import tempfile + +from twisted.internet import reactor +from twisted.internet.utils import getProcessValue + +from wader.common.oses.linux import LinuxPlugin +from wader.common.utils import save_file, get_file_data, create_dns_lock +from wader.common.consts import APP_NAME, WADER_DNS_LOCK + +dns_template = """ +nameserver\t%s +nameserver\t%s +""" + +class UbuntuBasedDistro(LinuxPlugin): + """A plugin to be used on Ubuntu systems""" + + def add_dns_info(self, (dns1, dns2), iface=None): + from wader.common.runtime import resolvconf_present + if not resolvconf_present: + # resolvconf package is not present, we will resort to + # using pppd's ip-{up,down}.d infrastructure. 95vmc-up + # will handle this for us. + create_dns_lock(dns1, dns2, WADER_DNS_LOCK) + else: + path = tempfile.mkstemp('resolv.conf', APP_NAME)[1] + save_file(path, dns_template % (dns1, dns2)) + + args = [iface, path] + return getProcessValue('/usr/bin/wader-resolvconf-helper', + args, reactor=reactor) + + def delete_dns_info(self, dnsinfo, iface=None): + from wader.common.runtime import resolvconf_present + if not resolvconf_present: + # 95vmc-down will handle this for us + return + + args = ['-d', iface] + return getProcessValue('/sbin/resolvconf', args, reactor=reactor) + + def is_valid(self): + if not exists('/etc/lsb-release'): + return False + + return 'Ubuntu' in get_file_data('/etc/lsb-release') + + +ubuntu = UbuntuBasedDistro() + diff --git a/resources/config/10-wader-modems.fdi b/resources/config/10-wader-modems.fdi new file mode 100644 index 0000000..2ce33bf --- /dev/null +++ b/resources/config/10-wader-modems.fdi @@ -0,0 +1,42 @@ + + + + + + + + + + + modem + GSM-07.07 + GSM-07.05 + + + + + + + + + + + modem + GSM-07.07 + GSM-07.05 + + + + + + modem + GSM-07.07 + GSM-07.05 + + + + + + + + diff --git a/resources/config/95wader-down b/resources/config/95wader-down new file mode 100755 index 0000000..f4ed745 --- /dev/null +++ b/resources/config/95wader-down @@ -0,0 +1,24 @@ +#!/bin/sh -e + +export PATH=/sbin:/bin:/usr/sbin:/usr/bin +RESOLVCONF="/etc/resolv.conf" +BACKRESOLV="/etc/resolv.wader.backup" +WADERCONN="/tmp/wader-conn.lock" + +! test -f "$WADERCONN" || exit 0 + +# remove connection lock +rm -f $WADERCONN + +# is dhclient running? +DHCP=`ps aux | grep dhclient | grep -v grep` + +if [ -n "$DHCP" ]; then + # we are going to back up resolv.conf + mv $BACKRESOLV $RESOLVCONF + # restart nscd because resolv.conf has changed + if [ -e /var/run/nscd.pid ]; then + /etc/init.d/nscd restart || true + fi +fi + diff --git a/resources/config/95wader-up b/resources/config/95wader-up new file mode 100755 index 0000000..5ca5eed --- /dev/null +++ b/resources/config/95wader-up @@ -0,0 +1,42 @@ +#!/bin/sh -e +export PATH=/sbin:/bin:/usr/sbin:/usr/bin + +RESOLVCONF="/etc/resolv.conf" +BACKRESOLV="/etc/resolv.wader.backup" +WADERCONN="/tmp/wader-conn.lock" +TEMPPATH=`mktemp` + +# Does WADERCONN exists? + +test -f "$WADERCONN" || exit 0 + +# remove stalled backup file just in case +rm -f $BACKRESOLV + +# get DNS addresses + +PRIMARYDNS=`grep DNS1 $WADERCONN | awk {'print $2'}` +SECONDARYDNS=`grep DNS2 $WADERCONN | awk {'print $2'}` + +create_resolvconf() +{ + # we are going to back up resolv.conf + mv $RESOLVCONF $BACKRESOLV + + # create new resolv.conf + cat >> $TEMPATH <<-EOA + nameserver $PRIMARYDNS + nameserver $SECONDARYDNS + EOA + mv $TEMPPATH $RESOLVCONF + # in Fedora 7 umask leaves /etc/resolv.conf as 0600 + chmod 644 $RESOLVCONF +} + +create_resolvconf +# restart nscd because resolv.conf has changed +if [ -e /var/run/nscd.pid ]; then + /etc/init.d/nscd restart || true +fi + +exit 0 diff --git a/resources/config/huawei-E169.conf b/resources/config/huawei-E169.conf new file mode 100644 index 0000000..72112b2 --- /dev/null +++ b/resources/config/huawei-E169.conf @@ -0,0 +1,5 @@ +DefaultVendor=0x12d1 +DefaultProduct=0x1001 + +DetachStorageOnly=1 +HuaweiMode=1 diff --git a/resources/config/novatel-MC950D.conf b/resources/config/novatel-MC950D.conf new file mode 100644 index 0000000..b3748c5 --- /dev/null +++ b/resources/config/novatel-MC950D.conf @@ -0,0 +1,6 @@ +DefaultVendor=0x1410 +DefaultProduct=0x5010 +TargetVendor=0x1410 +TargetProduct=0x4400 +MessageEndpoint=0x09 +MessageContent="5553424312345678000000000000061b000000020000000000000000000000" diff --git a/resources/config/option-icon-225.conf b/resources/config/option-icon-225.conf new file mode 100644 index 0000000..d7fc988 --- /dev/null +++ b/resources/config/option-icon-225.conf @@ -0,0 +1,5 @@ +DefaultVendor=0x0af0 +DefaultProduct=0x6971 +TargetClass=0xff +MessageEndpoint=0x05 +MessageContent="55534243123456780000000000000601000000000000000000000000000000" diff --git a/resources/config/wvdial.conf.tpl b/resources/config/wvdial.conf.tpl new file mode 100644 index 0000000..0c4f83e --- /dev/null +++ b/resources/config/wvdial.conf.tpl @@ -0,0 +1,20 @@ +[Dialer Defaults] + +Phone = *99***1# +Username = $username +Password = $password +Stupid Mode = 1 +Dial Command = ATDT +New PPPD = yes +Check Def Route = on +Dial Attempts = 3 + +[Dialer connect] + +Modem = $serialport +Baud = 460800 +Init2 = ATZ +Init3 = ATQ0 V1 E0 S0=0 &C1 &D2 +FCLASS=0 +Init4 = AT+CGDCONT=1,"IP","$apn" +ISDN = 0 +Modem Type = Analog Modem diff --git a/resources/dbus/org.freedesktop.ModemManager.conf b/resources/dbus/org.freedesktop.ModemManager.conf new file mode 100644 index 0000000..072ba7f --- /dev/null +++ b/resources/dbus/org.freedesktop.ModemManager.conf @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 512 + + diff --git a/resources/dbus/org.freedesktop.ModemManager.service b/resources/dbus/org.freedesktop.ModemManager.service new file mode 100644 index 0000000..62ede7a --- /dev/null +++ b/resources/dbus/org.freedesktop.ModemManager.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.freedesktop.ModemManager +Exec=/usr/bin/wader-core-ctl --start +User=root diff --git a/resources/extra/__init__.py b/resources/extra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/extra/networks.py b/resources/extra/networks.py new file mode 100644 index 0000000..8b67465 --- /dev/null +++ b/resources/extra/networks.py @@ -0,0 +1,374 @@ +# network operator list database +# if you need to add a new NetworkOperator do it here. If +# your network does not require a username/password, the convention +# is to use '*' as a dummy value, might not work otherwise. + +class NetworkOperator(object): + netid = [] + name = None + country = None + apn = None + username = None + password = None + dns1 = None + dns2 = None + + def __repr__(self): + args = (self.name, self.country, self.netid[0]) + return "" % args + + +class SFRFrance(NetworkOperator): + netid = ["20810"] + name = "SFR" + country = "France" + apn = "websfr" + username = "websfr" + password = "websfr" + dns1 = "172.20.2.10" + dns2 = "194.6.128.4" + + +class VodafoneSpain(NetworkOperator): + netid = ["21401"] + name = "Vodafone" + country = "Spain" + apn = "ac.vodafone.es" + username = "vodafone" + password = "vodafone" + dns1 = "212.73.32.3" + dns2 = "212.73.32.67" + + +class MovistarSpain(NetworkOperator): + netid = ["21402", "21407"] + name = "Movistar" + country = "Spain" + apn = "movistar.es" + username = "movistar" + password = "movistar" + dns1 = "194.179.1.100" + dns2 = "194.179.1.101" + + +class YoigoSpain(NetworkOperator): + netid = ["21403", "21404"] + name = "Yoigo" + country = "Spain" + apn = "internet" + username = "yoigo" + password = "yoigo" + dns1 = "10.8.0.20" + dns2 = "10.8.0.21" + + +class SimyoSpain(NetworkOperator): + netid = ["21419"] + name = "Simyo" + country = "Spain" + apn = "gprs-service.com" + username = "*" + password = "*" + dns1 = "217.18.32.170" + dns2 = "217.18.32.170" + + +class VIPCroatia(NetworkOperator): + netid = ["21910"] + name = "VIP" + country = "Croatia" + apn = "data.vip.hr" + username = "38591" + password = "38591" + dns1 = "212.91.97.3" + dns2 = "212.91.97.4" + + +class VodacomSouthAfrica(NetworkOperator): + netid = ["65501"] + name = "Vodacom" + country = "South Africa" + apn = "internet" + username = "vodafone" + password = "vodafone" + dns1 = "196.207.32.69" + dns2 = "196.43.1.11" + + +class VodafoneAustralia(NetworkOperator): + netid = ["50503"] + name = "Vodafone" + country = "Australia" + apn = "vfinternet.au" + username = "*" + password = "*" + dns1 = None + dns2 = None + + +class VodafoneItaly(NetworkOperator): + netid = ["22210"] + name = "Vodafone" + country = "Italy" + apn = "web.omnitel.it" + username = "vodafone" + password = "vodafone" + dns1 = "83.224.65.134" + dns2 = "83.224.66.234" + + +class VodafonePortugal(NetworkOperator): + netid = ["26801"] + name = "Vodafone" + country = "Portugal" + apn = "internet.vodafone.pt" + username = "vodafone" + password = "vodafone" + dns1 = "212.18.160.133" + dns2 = "212.18.160.134" + + +class VodafoneNetherlands(NetworkOperator): + netid = ["20404"] + name = "Vodafone" + country = "Netherlands" + apn = "live.vodafone.com" + username = "vodafone" + password = "vodafone" + dns1 = None + dns2 = None + + +class VodafoneGermany(NetworkOperator): + netid = ["26202"] + name = "Vodafone" + country = "Germany" + apn = "web.vodafone.de" + username = "vodafone" + password = "vodafone" + dns1 = "139.7.30.125" + dns2 = "139.7.30.126" + + +class NetComNorway(NetworkOperator): + netid = ["24202"] + name = "NetCom" + country = "Norway" + apn = "internet" + username = "internet" + password = "internet" + dns1 = "212.169.123.67" + dns2 = "212.45.188.254" + + +class MobileOneSingapore(NetworkOperator): + netid = ["52503"] + name = "MobileOne" + country = "Singapore" + apn = "sunsurf" + username = "M1" + password = "M1" + dns1 = "202.65.247.151" + dns2 = "202.65.247.151" + + +class TelkomSelIndonesia(NetworkOperator): + netid = ["51010"] + name = "TelkomSel" + country = "Indonesia" + apn = "flash" + username = "flash" + password = "flash" + dns1 = "202.3.208.10" + dns2 = "202.3.210.10" + + +class SATelindoIndonesia(NetworkOperator): + netid = ["51001"] + name = "PT. SATelindo C" + country = "Indonesia" + apn = "indosat3g" + username = "indosat" + password = "indosat" + dns1 = "202.155.46.66" + dns2 = "202.155.46.77" + + +class IM3Indonesia(NetworkOperator): + netid = ["51021"] + name = "IM3" + country = "Indonesia" + apn = "www.indosat-m3.net" + username = "im3" + password = "im3" + dns1 = "202.155.46.66" + dns2 = "202.155.46.77" + + +class O2UK(NetworkOperator): + netid = ["23411"] + name = "O2" + country = "UK" + apn = "mobile.o2.co.uk" + username = "o2web" + password = "password" + dns1 = "193.113.200.200" + dns2 = "193.113.200.201" + + +class OrangeUK(NetworkOperator): + netid = ["23433","23434"] + name = "Orange" + country = "UK" + apn = "orangeinternet" + username = "web" + password = "web" + dns1 = "158.43.192.1" + dns2 = "158.43.128.1" + + +class ProXLndonesia(NetworkOperator): + netid = ["51011"] + name = "Pro XL" + country = "Indonesia" + apn = "www.xlgprs.net" + username = "xlgprs" + password = "proxl" + dns1 = "202.152.254.245" + dns2 = "202.152.254.246" + + +class TMNPortugal(NetworkOperator): + netid = ["26806"] + name = "TMN" + country = "Portugal" + apn = "internet" + username = "tmn" + password = "tmnnet" + dns1 = None + dns2 = None + + +class ThreeItaly(NetworkOperator): + netid = ["22299"] + name = "3" + country = "Italy" + apn = "naviga.tre.it" + username = "anon" + password = "anon" + dns1 = "62.13.171.1" + dns2 = "62.13.171.2" + + +class ThreeAustralia(NetworkOperator): + netid = ["50503"] + name = "3" + country = "Australia" + apn = "3netaccess" + username = "*" + password = "*" + dns1 = None + dns2 = None + + +class ThreeUK(NetworkOperator): + netid = ["23420"] + name = "3" + country = "UK" + apn = "3internet" + username = "three" + password = "three" + dns1 = "172.31.76.69" + dns2 = "172.31.140.69" + + +class TMobileUK(NetworkOperator): + netid = ["23430", "23431", "23432"] + name = "T-Mobile" + country = "UK" + apn = "general.t-mobile.uk" + username = "*" + password = "*" + dns1 = "149.254.192.126" + dns2 = "149.254.201.126" + + +class TimItaly(NetworkOperator): + netid = ["22201"] + name = "TIM" + country = "Italy" + apn = "ibox.tim.it" + username = "anon" + password = "anon" + dns1 = None + dns2 = None + + +class WindItaly(NetworkOperator): + netid = ["22288"] + name = "Wind" + country = "Italy" + apn = "internet.wind" + username = "anon" + password = "anon" + dns1 = None + dns2 = None + + +class ChinaMobile(NetworkOperator): + netid = ["46000"] + name = "China Mobile" + country = "China" + apn = "cmnet" + username = "zte" + password = "zte" + dns1 = None + dns2 = None + + +class OmnitelLithuania(NetworkOperator): + netid = ["24601"] + name = "Omnitel" + country = "Lithuania" + apn = "omnitel" + username = "omni" + password = "omni" + dns1 = None + dns2 = None + + +class BiteLithuania(NetworkOperator): + netid = ["24602"] + name = "Bite" + country = "Lithuania" + apn = "banga" + username = "*" + password = "*" + dns1 = None + dns2 = None + + +class Tele2Lithuania(NetworkOperator): + netid = ["24603"] + name = "Tele2" + country = "Lithuania" + apn = "internet.tele2.lt" + username = "wap" + password = "wap" + dns1 = "130.244.127.161" + dns2 = "130.244.127.169" + + +class UnitelAngola(NetworkOperator): + netid = ["63102"] + name = "Unitel" + country = "Angola" + apn = "internet.unitel.co.ao" + username = "*" + password = "*" + dns1 = None + dns2 = None + + +if __name__ == '__main__': + print VodafoneSpain() diff --git a/resources/rpm/wader.spec b/resources/rpm/wader.spec new file mode 100644 index 0000000..f40d103 --- /dev/null +++ b/resources/rpm/wader.spec @@ -0,0 +1,172 @@ +%define wader_root %{_datadir}/wader-core +%define python_sitearch %(%{__python} -c 'from distutils import sysconfig; print sysconfig.get_python_lib()') + +Name: wader +Version: 0.3.6 +Release: 1%{?dist} +Summary: A ModemManager implementation written in Python +Source: ftp://ftp.noexists.org/pub/wader/%{name}-%{version}.tar.bz2 +Group: Applications/Telephony +License: GPL + +%description +Wader is a fork of the core of "Vodafone Mobile Connect Card driver for Linux", +with some of its parts rewritten and improved to be able to interact via DBus +with other applications of the Linux/OSX desktop. Wader has two main +components, a core and a simple UI. The core can be extended to support more +devices and distros/OSes through plugins. + +%package core +Summary: The core of Wader +Group: Applications/Telephony +BuildRoot: %{_tmppath}/%{name}-%{version}-build +BuildRequires: python-devel, python-setuptools +Requires: /usr/bin/eject +%if 0%{?suse_version} +BuildRequires: hal, dbus-1-python, python-messaging, python-epsilon, python-zopeinterface, update-desktop-files +Requires: python-twisted, python-serial, dbus-1-python +%else +BuildRequires: dbus-python, python-zope-interface, python-twisted-core +Requires: python-twisted-core, pyserial, dbus-python +%endif +%if %{defined moblin} +BuildRequires: gettext +%endif + +Requires: python-twisted-conch, python-crypto, python-pytz, python-messaging, python-epsilon, usb_modeswitch, ozerocdoff +Conflicts: ModemManager +Provides: org.freedesktop.ModemManager + +%description core +Wader core is a full ModemManager v0.2 implementation. It can be extended to +support more devices and distros/OSes through plugins. + +%prep +%setup -q + +%build +%{__make} -C resources/po/ mo + +CFLAGS="%{optflags}" %{__python} setup.py build +CFLAGS="%{optflags}" %{__python} setup-gtk.py build + +%install +%{__python} setup.py install --skip-build --root=%{buildroot} --prefix=%{_prefix} +%{__python} setup-gtk.py install --skip-build --root=%{buildroot} --prefix=%{_prefix} + +# gettext +%{__mkdir_p} %{buildroot}%{_datadir} +%{__cp} -R resources/po/locale %{buildroot}%{_datadir} + +# ppp-ip scripts +%{__mkdir_p} %{buildroot}%{_sysconfdir}/ppp/ip-up.d +%{__mkdir_p} %{buildroot}%{_sysconfdir}/ppp/ip-down.d +%{__install} -m0755 resources/config/95wader-up %{buildroot}%{_sysconfdir}/ppp/ip-up.d/95wader-up +%{__install} -m0755 resources/config/95wader-down %{buildroot}%{_sysconfdir}/ppp/ip-down.d/95wader-down + +%if 0%{?suse_version} +%suse_update_desktop_file wader-gtk +%endif + +# avoid %ghost warning +touch %{buildroot}%{_datadir}/wader-core/plugins/dropin.cache + +%clean +%{__rm} -rf %{buildroot} + +%post core +if [ $1 = 1 ]; then + # kill modem-manager asap + kill -9 `pidof modem-manager` 2> /dev/null +fi +if [ $1 = 2 ]; then + # remove traces of old dir + if [ -d /usr/share/wader ]; then + rm -rf /usr/share/wader + fi + # update plugins cache + rm -rf /usr/share/wader-core/plugins/dropin.cache + python -c "from twisted.plugin import IPlugin, getPlugins;import wader.plugins; list(getPlugins(IPlugin, package=wader.plugins))" + # restart wader-core + if [ -e /var/run/wader.pid ]; then + /usr/bin/wader-core-ctl --restart 2>/dev/null || true + fi +fi + +%files core +%defattr(-,root,root) +%dir %{python_sitearch}/wader +%{python_sitearch}/Wader-* +%dir %{python_sitearch}/wader/common/ +%dir %{python_sitearch}/wader/common/hardware/ +%dir %{python_sitearch}/wader/common/oses/ +%dir %{python_sitearch}/wader/common/statem/ +%dir %{python_sitearch}/wader/common/dialers/ +%dir %{python_sitearch}/wader/contrib/ +%dir %{python_sitearch}/wader/test/ +%dir %{python_sitearch}/wader/plugins/ + +%{python_sitearch}/wader/*.py +%{python_sitearch}/wader/*.pyc +%{python_sitearch}/wader/common/*.py +%{python_sitearch}/wader/common/*.pyc +%{python_sitearch}/wader/common/hardware/*.py +%{python_sitearch}/wader/common/hardware/*.pyc +%{python_sitearch}/wader/common/oses/*.py +%{python_sitearch}/wader/common/oses/*.pyc +%{python_sitearch}/wader/common/statem/*.py +%{python_sitearch}/wader/common/statem/*.pyc +%{python_sitearch}/wader/common/dialers/*.py +%{python_sitearch}/wader/common/dialers/*.pyc +%{python_sitearch}/wader/contrib/*.py +%{python_sitearch}/wader/contrib/*.pyc +%{python_sitearch}/wader/test/*.py +%{python_sitearch}/wader/test/*.pyc +%{python_sitearch}/wader/plugins/*.py +%{python_sitearch}/wader/plugins/*.pyc + +%dir %{wader_root}/ +%dir %{wader_root}/plugins/ +%{wader_root}/*.py +%{wader_root}/plugins/*.py + +%dir %{wader_root}/resources +%{wader_root}/resources/config +%{wader_root}/resources/extra +%ghost %{wader_root}/plugins/dropin.cache + +%dir %{_sysconfdir}/udev +%dir %{_sysconfdir}/udev/rules.d + +%config %{_datadir}/dbus-1/system-services/org.freedesktop.ModemManager.service +%config %{_sysconfdir}/dbus-1/system.d/org.freedesktop.ModemManager.conf +%config %{_sysconfdir}/udev/rules.d/99-huawei-e169.rules +%config %{_sysconfdir}/udev/rules.d/99-novatel-eu870d.rules +%config %{_sysconfdir}/udev/rules.d/99-novatel-mc950d.rules +%config %{_sysconfdir}/udev/rules.d/99-novatel-mc990d.rules +%config %{_sysconfdir}/udev/rules.d/99-option-icon-225.rules + +%{_sysconfdir}/ppp/ip-down.d/95wader-down +%{_sysconfdir}/ppp/ip-up.d/95wader-up +%dir %{_datadir}/hal/fdi/information/20thirdparty +%{_datadir}/hal/fdi/information/20thirdparty/10-wader-modems.fdi + +%{_bindir}/wader-core-ctl + +%doc LICENSE README NEWS + +%changelog +* Tue May 05 2009 Pablo Marti 0.3.6 +- 0.3.6 Release +* Fri Apr 03 2009 Pablo Marti 0.3.5 +- 0.3.5 Release +* Tue Mar 03 2009 Pablo Marti 0.3.4 +- 0.3.4 Release +* Mon Feb 23 2009 Pablo Marti 0.3.3 +- 0.3.3 Release +* Thu Feb 13 2009 Pablo Marti 0.3.2 +- 0.3.2 Release +* Mon Feb 02 2009 Pablo Marti 0.3.1 +- 0.3.1 Release +* Mon Dec 01 2008 Pablo Marti 0.3.0 +- 0.3.0 Release diff --git a/resources/udev/99-huawei-e169.rules b/resources/udev/99-huawei-e169.rules new file mode 100644 index 0000000..fd22549 --- /dev/null +++ b/resources/udev/99-huawei-e169.rules @@ -0,0 +1,13 @@ +# original file from the MobileManager project +# imported in Wader by Pablo Martí on 10 Jul 2008 + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idVendor}=="12d1", ATTRS{idProduct}=="1001", GROUP="uucp", MODE="0666", OPTIONS="last_rule" + +SUBSYSTEM!="usb", GOTO="huawei_e169_rules_end" +ACTION!="add", GOTO="huawei_e169_rules_end" + +ATTRS{idVendor}=="12d1", ATTRS{idProduct}=="1001", RUN+="/usr/sbin/usb_modeswitch -c /usr/share/wader-core/resources/config/huawei-E169.conf" + +LABEL="huawei_e169_rules_end" + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idVendor}=="12d1", ATTRS{idProduct}=="1001", GROUP="uucp", MODE="0666", OPTIONS="last_rule" diff --git a/resources/udev/99-novatel-eu870d.rules b/resources/udev/99-novatel-eu870d.rules new file mode 100644 index 0000000..7675b87 --- /dev/null +++ b/resources/udev/99-novatel-eu870d.rules @@ -0,0 +1,15 @@ +# original file from the MobileManager project +# imported in Wader by Pablo Martí on 10 Jul 2008 + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idProduct}=="2420", ATTRS{idVendor}=="1410", GROUP="uucp", MODE="0666", OPTIONS="last_rule" + +SUBSYSTEM!="usb", GOTO="novatel_eu870d_rules_end" +ACTION!="add", GOTO="novatel_eu870d_rules_end" + +ATTRS{idProduct}=="2420", ATTRS{idVendor}=="1410", RUN+="/sbin/rmmod usbserial;/bin/sleep 4;/sbin/modprobe usbserial vendor=0x1410 product=0x2420" +ATTRS{idProduct}=="2420", ATTRS{idVendor}=="1410", RUN+="/bin/sleep 4" +ATTRS{idProduct}=="2420", ATTRS{idVendor}=="1410", RUN+="/sbin/modprobe usbserial vendor=0x1410 product=0x2420" + +LABEL="novatel_eu870d_rules_end" + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idProduct}=="2420", ATTRS{idVendor}=="1410", GROUP="uucp", MODE="0666", OPTIONS="last_rule" diff --git a/resources/udev/99-novatel-mc950d.rules b/resources/udev/99-novatel-mc950d.rules new file mode 100644 index 0000000..e72e522 --- /dev/null +++ b/resources/udev/99-novatel-mc950d.rules @@ -0,0 +1,13 @@ +# original file from the MobileManager project +# imported in Wader by Pablo Martí on 10 Jul 2008 + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idProduct}=="5010", ATTRS{idVendor}=="1410", GROUP="uucp", MODE="0666", OPTIONS="last_rule" + +SUBSYSTEM!="usb", GOTO="novatel_mc950d_rules_end" +ACTION!="add", GOTO="novatel_mc950d_rules_end" + +ATTRS{idProduct}=="5010", ATTRS{idVendor}=="1410", RUN+="/usr/sbin/usb_modeswitch -c /usr/share/wader-core/resources/config/novatel-MC950D.conf" + +LABEL="novatel_mc950d_rules_end" + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idProduct}=="5010", ATTRS{idVendor}=="1410", GROUP="uucp", MODE="0666", OPTIONS="last_rule" diff --git a/resources/udev/99-novatel-mc990d.rules b/resources/udev/99-novatel-mc990d.rules new file mode 100644 index 0000000..42856f4 --- /dev/null +++ b/resources/udev/99-novatel-mc990d.rules @@ -0,0 +1 @@ +ACTION=="add", SYSFS{idVendor}=="1410", SYSFS{idProduct}=="5020", RUN:="/usr/bin/eject %k" diff --git a/resources/udev/99-option-icon-225.rules b/resources/udev/99-option-icon-225.rules new file mode 100644 index 0000000..751620c --- /dev/null +++ b/resources/udev/99-option-icon-225.rules @@ -0,0 +1,13 @@ +# original file from the MobileManager project +# imported in Wader by Pablo Martí on 10 Jul 2008 + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idVendor}=="0af0", ATTRS{idProduct}=="6971", GROUP="uucp", MODE="0666", OPTIONS="last_rule" + +SUBSYSTEM!="usb", GOTO="option_icon_225_rules_end" +ACTION!="add", GOTO="option_icon_225_rules_end" + +ATTRS{idVendor}=="0af0", ATTRS{idProduct}=="6971", RUN+="/usr/sbin/usb_modeswitch -c /usr/share/wader-core/resources/config/option-icon-225.conf" + +LABEL="option_icon_225_rules_end" + +KERNEL=="ttyUSB[0-9]*" , ATTRS{idVendor}=="0af0", ATTRS{idProduct}=="6971", GROUP="uucp", MODE="0666", OPTIONS="last_rule" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9cf8751 --- /dev/null +++ b/setup.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +setuptools file for Wader +""" + +from os.path import join, isdir, walk +import sys + +from ez_setup import use_setuptools; use_setuptools() +from distutils.core import Extension +from setuptools import setup + +from wader.common.consts import (APP_VERSION, APP_NAME, + APP_SLUG_NAME) + +DATA_DIR = '/usr/share/%s' % APP_SLUG_NAME +BIN_DIR = '/usr/bin' +RESOURCES = join(DATA_DIR, 'resources') +DBUS_SYSTEMD = '/etc/dbus-1/system.d' +DBUS_SYSTEM_SERVICES = '/usr/share/dbus-1/system-services' +UDEV_RULESD = '/etc/udev/rules.d' + +FDI_THIRDPARTY = '/usr/share/hal/fdi/information/20thirdparty' + +def list_files(path, exclude=None): + result = [] + def walk_callback(arg, directory, files): + for ext in ['.svn', '.git']: + if ext in files: + files.remove(ext) + if exclude: + for f in files: + if f.startswith(exclude): + files.remove(f) + result.extend(join(directory, f) for f in files + if not isdir(join(directory, f))) + + walk(path, walk_callback, None) + return result + +data_files = [ + (join(RESOURCES, 'extra'), list_files('resources/extra')), + (join(RESOURCES, 'config'), list_files('resources/config')), + (join(DATA_DIR, 'plugins'), list_files('plugins')), + (DATA_DIR, ['core-tap.py']), + (BIN_DIR, ['bin/wader-core-ctl']), +] + +ext_modules = [] + +if sys.platform == 'linux2': + append = data_files.append + append((DBUS_SYSTEMD, + ['resources/dbus/org.freedesktop.ModemManager.conf'])) + append((DBUS_SYSTEM_SERVICES, + ['resources/dbus/org.freedesktop.ModemManager.service'])) + append((UDEV_RULESD, list_files('resources/udev'))) + append((FDI_THIRDPARTY, ['resources/config/10-wader-modems.fdi'])) + +elif sys.platform == 'darwin': + osxserialports = Extension('osxserialports', + sources=['contrib/osxserialports/osxserialportsmodule.c'], + extra_link_args=['-framework', 'CoreFoundation', + '-framework', 'IOKit']) + ext_modules.append(osxserialports) + +packages = [ + 'wader', 'wader.common', 'wader.common.oses', 'wader.common.dialers', + 'wader.common.statem', 'wader.common.hardware', 'wader.contrib', + 'wader.test', 'wader.plugins' +] + +setup(name=APP_NAME, + version=APP_VERSION, + description='3G device manager for Linux and OSX', + download_url="http://www.wader-project.org", + author='Pablo Martí Gamboa', + author_email='pmarti@warp.es', + license='GPL', + packages=packages, + data_files=data_files, + ext_modules=ext_modules, + zip_safe=False, + test_suite='wader.test', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: No Input/Output (Daemon)', + 'Framework :: Twisted', + 'Intended Audience :: Developers', + 'Intended Audience :: Telecommunications Industry', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Natural Language :: English', + 'Operating System :: POSIX :: Linux', + 'Operating System :: MacOS :: MacOS X', + 'Programming Language :: Python :: 2.5', + 'Programming Language :: Python :: 2.6', + 'Topic :: Communications :: Telephony', + ] +) diff --git a/wader/__init__.py b/wader/__init__.py new file mode 100644 index 0000000..3139e82 --- /dev/null +++ b/wader/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# setup version +from wader._version import version +__version__ = version.short() diff --git a/wader/_version.py b/wader/_version.py new file mode 100644 index 0000000..3188a43 --- /dev/null +++ b/wader/_version.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from wader.common.consts import APP_VERSION, APP_NAME +from twisted.python import versions + +try: + major, minor, rev = map(int, APP_VERSION.split('.')) + version = versions.Version(APP_NAME, major, minor, rev) +except (TypeError, ValueError): + major, minor, rev, micro = map(int, APP_VERSION.split('.')) + version = versions.Version(APP_NAME, major, minor, rev, micro) diff --git a/wader/common/__init__.py b/wader/common/__init__.py new file mode 100644 index 0000000..5e350e0 --- /dev/null +++ b/wader/common/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Wader's core""" diff --git a/wader/common/_dbus.py b/wader/common/_dbus.py new file mode 100644 index 0000000..b8c7eb9 --- /dev/null +++ b/wader/common/_dbus.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""DBus-related helper classes""" + +import dbus +import dbus.service +from twisted.python import log + +class DBusComponent(object): + """I provide a couple of useful methods to deal with DBus""" + + def __init__(self): + self.bus = dbus.SystemBus() + self.obj = self.bus.get_object('org.freedesktop.Hal', + '/org/freedesktop/Hal/Manager') + self.manager = dbus.Interface(self.obj, 'org.freedesktop.Hal.Manager') + + def get_properties_from_udi(self, udi): + """Returns all the properties from ``udi``""" + obj = self.bus.get_object('org.freedesktop.Hal', udi) + dev = dbus.Interface(obj, 'org.freedesktop.Hal.Device') + return dev.GetAllProperties() + + def get_devices_properties(self): + """Returns all the properties from all devices registed in HAL""" + props = {} + for udi in self.manager.GetAllDevices(): + props[udi] = self.get_properties_from_udi(udi) + return props + + +class DBusExporterHelper(object): + """I am a helper for classes that export methods over DBus""" + def __init__(self): + super(DBusExporterHelper, self).__init__() + + def add_callbacks(self, deferred, async_cb, async_eb): + """Adds ``async_cb`` and ``async_eb`` to ``deferred``""" + deferred.addCallback(async_cb) + deferred.addErrback(self._process_failure, async_eb) + return deferred + + def add_callbacks_and_swallow(self, deferred, async_cb, async_eb): + """ + Like previous method but swallows the result + + This method is useful for functions that might return some garbage, + but we are not interested in the result + """ + deferred.addCallback(lambda _: async_cb()) + deferred.addErrback(self._process_failure, async_eb) + return deferred + + def _process_failure(self, failure, async_eb): + """ + Extracts the exception wrapped in ``failure`` and calls ``async_eb`` + """ + try: + async_eb(failure.type(failure.value)) + except: + log.msg(dir(failure.type)) + log.msg(failure.value) + + +class DelayableDBusObject(dbus.service.Object): + """Use me in classes that need to make asynchronous a synchronous method""" + def __init__(self, *args): + super(DelayableDBusObject, self).__init__(*args) + + def _message_cb(self, connection, message): + method, parent_method = dbus.service._method_lookup(self, + message.get_member(), + message.get_interface()) + + super(DelayableDBusObject, self)._message_cb(connection, message) + + if "_dbus_is_delayable" in dir(parent_method): + #if hasattr(parent_method, '_dbus_is_delayable'): + member = message.get_member() + signature_str = parent_method._dbus_out_signature + signature = dbus.service.Signature(signature_str) + + def callback(result): + dbus.service._method_reply_return(connection, + message, + member, + signature, + *result) + def errback(e): + dbus.service._method_reply_error(connection, message, e) + + parent_method._finished(self, callback, errback) + + +def delayable(func): + """ + Make a synchronous method asynchronous + + decorator to be used on subclasses of :class:`DelayableDBusObject` + """ + assert func._dbus_is_method + + def delay_reply(): + func._dbus_async_callbacks_before = func._dbus_async_callbacks + func._dbus_async_callbacks = True + + def finished(self, cb, eb): + if func._dbus_async_callbacks == True: + if not self in func._reply_callbacks: + func._reply_callbacks[self] = [] + func._reply_callbacks[self].append((cb, eb)) + func._dbus_async_callbacks = func._dbus_async_callbacks_before + + def reply(self, result=None, error=None): + if self in func._reply_callbacks: + for callback, errback in func._reply_callbacks[self]: + if error: + errback(error) + elif result: + callback(result) + else: + callback() + del func._reply_callbacks[self] + return True + else: + return False + + func._reply_callbacks = {} + func._dbus_is_delayable = True + + func.delay_reply = delay_reply + func.reply = reply + func._finished = finished + return func + diff --git a/wader/common/_gconf.py b/wader/common/_gconf.py new file mode 100644 index 0000000..a1635a5 --- /dev/null +++ b/wader/common/_gconf.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""GConf helper classes""" + +import gconf + +class GConfHelper(object): + """I am the base class for gconf-backed conf system""" + + def __init__(self): + self.client = gconf.client_get_default() + + def set_value(self, path, value): + """Sets ``value`` at ``path``""" + if isinstance(value, basestring): + self.client.set_string(path, value) + elif isinstance(value, bool): + self.client.set_bool(path, value) + elif isinstance(value, (int, long)): + self.client.set_int(path, value) + elif isinstance(value, float): + self.client.set_float(path, value) + elif isinstance(value, list): + self.client.set_list(path, gconf.VALUE_INT, value) + + def get_value(self, value): + """Gets the value of ``value``""" + if value.type == gconf.VALUE_STRING: + return value.get_string() + elif value.type == gconf.VALUE_INT: + return value.get_int() + elif value.type == gconf.VALUE_FLOAT: + return value.get_float() + elif value.type == gconf.VALUE_BOOL: + return value.get_bool() + elif value.type == gconf.VALUE_LIST: + _list = value.get_list() + return [self.get_value(v) for v in _list] + else: + msg = "Unhandleable type %s for %s" + raise TypeError(msg % (type(value), value)) + diff --git a/wader/common/aterrors.py b/wader/common/aterrors.py new file mode 100644 index 0000000..b6987ec --- /dev/null +++ b/wader/common/aterrors.py @@ -0,0 +1,537 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""AT commands errors related functions and exceptions""" + +import re + +import dbus +from twisted.python import log + +CTS_ERROR = 'org.freedesktop.ModemManager.Error.Contacts' +NET_ERROR = 'org.freedesktop.ModemManager.Error.Network' +PIN_ERROR = 'org.freedesktop.ModemManager.Error.PIN' +SMS_ERROR = 'org.freedesktop.ModemManager.Error.SMS' +GEN_ERROR = 'org.freedesktop.ModemManager.Error' +MM_MODEM_ERROR = 'org.freedesktop.ModemManager.Gsm' + +ERROR_REGEXP = re.compile(r""" +# This regexp matches the following patterns: +# ERROR +# +CMS ERROR: 500 +# +CME ERROR: foo bar +# +CME ERROR: 30 +# +\r\n +(?P # group named error +\+CMS\sERROR:\s\d{3} | # CMS ERROR regexp +\+CME\sERROR:\s\S+(\s\S+)* | # CME ERROR regexp +\+CME\sERROR:\s\d+ | # CME ERROR regexp +INPUT\sVALUE\sIS\sOUT\sOF\sRANGE | # INPUT VALUE IS OUT OF RANGE +ERROR # Plain ERROR regexp +) +\r\n +""", re.VERBOSE) + +class GenericError(dbus.DBusException): + """Exception raised when an ERROR has occurred""" + _dbus_error_name = GEN_ERROR + +class InputValueError(dbus.DBusException): + """Exception raised when INPUT VALUE IS OUT OF RANGE is received""" + _dbus_error_name = GEN_ERROR + +class SerialResponseTimeout(dbus.DBusException): + """Serial response timed out""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SerialResponseTimeout') + +class PhoneFailure(dbus.DBusException): + """Phone failure""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'PhoneFailure') + +class NoConnection(dbus.DBusException): + """No connection to phone""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NoConnection') + +class LinkReserved(dbus.DBusException): + """Phone-adaptor linkreserved""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'LinkReserved') + +class OperationNotAllowed(dbus.DBusException): + """Operation not allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'OperationNotAllowed') + +class OperationNotSupported(dbus.DBusException): + """Operation not supported""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'OperationNotSupported') + +class PhSimPinRequired(dbus.DBusException): + """PH-SIM PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'PhSimPinRequired') + +class PhFSimPinRequired(dbus.DBusException): + """PH-FSIM PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'PhFSimPinRequired') + +class PhFPukRequired(dbus.DBusException): + """PH-FSIM PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'PhFPukRequired') + +class SimNotInserted(dbus.DBusException): + """PH-PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimNotInserted') + +class SimPinRequired(dbus.DBusException): + """SIM PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimPinRequired') + +class SimPukRequired(dbus.DBusException): + """SIM PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimPukRequired') + +class SimFailure(dbus.DBusException): + """SIM failure""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimFailure') + +class SimBusy(dbus.DBusException): + """SIM busy""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimBusy') + +class SimWrong(dbus.DBusException): + """SIM wrong""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimWrong') + +class SimNotStarted(dbus.DBusException): + """SIM interface not started""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimNotStarted') + +class IncorrectPassword(dbus.DBusException): + """Incorrect password""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'IncorrectPassword') + +class SimPin2Required(dbus.DBusException): + """SIM PIN2 required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimPin2Required') + +class SimPuk2Required(dbus.DBusException): + """SIM PUK2 required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'SimPuk2Required') + +class MemoryFull(dbus.DBusException): + """Memory full""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'MemoryFull') + +class InvalidIndex(dbus.DBusException): + """Index invalid""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'InvalidIndex') + +class NotFound(dbus.DBusException): + """Not found""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NotFound') + +class MemoryFailure(dbus.DBusException): + """Memory failure""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'MemoryFailure') + +class TextTooLong(dbus.DBusException): + """Text string too long""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'TextTooLong') + +class InvalidChars(dbus.DBusException): + """Invalid characters in text string""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'InvalidChars') + +class DialStringTooLong(dbus.DBusException): + """Invalid dial string""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'DialStringTooLong') + +class InvalidDialString(dbus.DBusException): + """Invalid dial string""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'InvalidDialString') + +class NoNetwork(dbus.DBusException): + """No network service""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NoNetwork') + +class NetworkTimeout(dbus.DBusException): + """Network timeout""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkTimeout') + +class NetworkNotAllowed(dbus.DBusException): + """Only emergency calls are allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkNotAllowed') + +class NetworkPinRequired(dbus.DBusException): + """Network PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkPinRequired') + +class NetworkPukRequired(dbus.DBusException): + """Network PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkPukRequired') + +class NetworkSubsetPinRequired(dbus.DBusException): + """Network subset PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkSubsetPinRequired') + +class NetworkSubsetPukRequired(dbus.DBusException): + """Network subset PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'NetworkSubsetPukRequired') + +class ServicePinRequired(dbus.DBusException): + """Service PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'ServicePinRequired') + +class ServicePukRequired(dbus.DBusException): + """Service PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'ServicePukRequired') + +class CharsetError(dbus.DBusException): + """Raised when Wader can't find an appropriate charset at startup""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'CharsetError') + +class CorporatePinRequired(dbus.DBusException): + """Corporate PIN required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'CorporatePinRequired') + +class CorporatePukRequired(dbus.DBusException): + """Corporate PUK required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'CorporatePukRequired') + +class HiddenKeyRequired(dbus.DBusException): + """Hidden key required""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'HiddenKeyRequired') + +class EapMethodNotSupported(dbus.DBusException): + """EAP method not supported""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'EapMethodNotSupported') + +class IncorrectParams(dbus.DBusException): + """Incorrect params received""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'IncorrectParams') + +class Unknown(dbus.DBusException): + """Unknown error""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'Unknown') + +class GprsIllegalMs(dbus.DBusException): + """Illegal GPRS MS""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsIllegalMs') + +class GprsIllegalMe(dbus.DBusException): + """Illegal GPRS ME""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsIllegalMe') + +class GprsServiceNotAllowed(dbus.DBusException): + """GPRS service not allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsServiceNotAllowed') + +class GprsPlmnNotAllowed(dbus.DBusException): + """GPRS PLMN not allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsPlmnNotAllowed') + +class GprsLocationNotAllowed(dbus.DBusException): + """GPRS location not allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsLocationNotAllowed') + +class GprsRoamingNotAllowed(dbus.DBusException): + """GPRS roaming not allowed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsRoamingNotAllowed') + +class GprsOptionNotSupported(dbus.DBusException): + """GPRS used option not supported""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsOptionNotSupported') + +class GprsNotSubscribed(dbus.DBusException): + """GPRS not susbscribed""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsNotSubscribed') + +class GprsOutOfOrder(dbus.DBusException): + """GPRS out of order""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsOutOfOrder') + +class GprsPdpAuthFailure(dbus.DBusException): + """GPRS PDP authentication failure""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsPdpAuthFailure') + +class GprsUnspecified(dbus.DBusException): + """Unspecified GPRS error""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsUnspecified') + +class GprsInvalidClass(dbus.DBusException): + """Invalid GPRS class""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'GprsInvalidClass') + +class ServiceTemporarilyOutOfOrder(dbus.DBusException): + """Exception raised when service temporarily out of order""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, + 'ServiceTemporarilyOutOfOrder') + +class UnknownSubscriber(dbus.DBusException): + """Exception raised when suscriber unknown""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'UnknownSubscriber') + +class ServiceNotInUse(dbus.DBusException): + """Exception raised when service not in use""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'ServiceNotInUse') + +class UnknownNetworkMessage(dbus.DBusException): + """Exception raised upon unknown network message""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'UnknownNetworkMessage') + +class CallIndexError(dbus.DBusException): + """Exception raised upon call index error""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'CallIndexError') + +class CallStateError(dbus.DBusException): + """Exception raised upon call state error""" + _dbus_error_name = "%s.%s" % (MM_MODEM_ERROR, 'IncorrectPassword') + +class CMSError300(dbus.DBusException): + """Phone failure""" + +class CMSError301(dbus.DBusException): + """SMS service of phone reserved """ + +class CMSError302(dbus.DBusException): + """Operation not allowed""" + +class CMSError303(dbus.DBusException): + """Operation not supported""" + +class CMSError304(dbus.DBusException): + """Invalid PDU mode parameter""" + +class CMSError305(dbus.DBusException): + """Invalid text mode parameter""" + +class CMSError310(dbus.DBusException): + """SIM not inserted""" + +class CMSError311(dbus.DBusException): + """SIM PIN necessary""" + +class CMSError313(dbus.DBusException): + """SIM failure""" + +class CMSError314(dbus.DBusException): + """SIM busy""" + +class CMSError315(dbus.DBusException): + """SIM wrong""" + +class CMSError320(dbus.DBusException): + """Memory failure""" + +class CMSError321(dbus.DBusException): + """Invalid memory index""" + +class CMSError322(dbus.DBusException): + """Memory full""" + +class CMSError330(dbus.DBusException): + """SMSC address unknown""" + +class CMSError331(dbus.DBusException): + """No network service""" + +class CMSError500(dbus.DBusException): + """Unknown Error""" + +ERROR_DICT = { + # Generic error + 'ERROR' : GenericError, + + # CME Errors + '+CME ERROR: incorrect password' : IncorrectPassword, + '+CME ERROR: invalid characters in dial string' : InvalidDialString, + '+CME ERROR: no network service' : NoNetwork, + '+CME ERROR: not found' : NotFound, + '+CME ERROR: operation not allowed' : OperationNotAllowed, + '+CME ERROR: text string too long': TextTooLong, + '+CME ERROR: SIM busy' : SimBusy, + '+CME ERROR: SIM failure' : SimFailure, + '+CME ERROR: SIM interface not started' : SimNotStarted, + '+CME ERROR: SIM interface not started yet' : SimNotStarted, + '+CME ERROR: SIM not inserted' : SimNotInserted, + '+CME ERROR: SIM PIN required' : SimPinRequired, + '+CME ERROR: SIM PUK required' : SimPukRequired, + '+CME ERROR: SIM PUK2 required' : SimPuk2Required, + + # NUMERIC CME ERRORS + '+CME ERROR: 0' : PhoneFailure, + '+CME ERROR: 1' : NoConnection, + '+CME ERROR: 3' : OperationNotAllowed, + '+CME ERROR: 4' : OperationNotSupported, + '+CME ERROR: 5' : PhSimPinRequired, + '+CME ERROR: 6' : PhFSimPinRequired, + '+CME ERROR: 7' : PhFPukRequired, + '+CME ERROR: 10' : SimNotInserted, + '+CME ERROR: 11' : SimPinRequired, + '+CME ERROR: 12' : SimPukRequired, + '+CME ERROR: 13' : SimFailure, + '+CME ERROR: 14' : SimBusy, + '+CME ERROR: 15' : SimWrong, + '+CME ERROR: 16' : IncorrectPassword, + '+CME ERROR: 17' : SimPin2Required, + '+CME ERROR: 18' : SimPuk2Required, + '+CME ERROR: 20' : MemoryFull, + '+CME ERROR: 21' : InvalidIndex, + '+CME ERROR: 22' : NotFound, + '+CME ERROR: 23' : MemoryFailure, + '+CME ERROR: 24' : TextTooLong, + '+CME ERROR: 26' : DialStringTooLong, + '+CME ERROR: 27' : InvalidDialString, + '+CME ERROR: 30' : NoNetwork, + '+CME ERROR: 31' : NetworkTimeout, + '+CME ERROR: 32' : NetworkNotAllowed, + '+CME ERROR: 40' : NetworkPinRequired, + '+CME ERROR: 41' : NetworkPukRequired, + '+CME ERROR: 42' : NetworkSubsetPinRequired, + '+CME ERROR: 43' : NetworkSubsetPukRequired, + '+CME ERROR: 44' : ServicePinRequired, + '+CME ERROR: 45' : ServicePukRequired, + '+CME ERROR: 46' : CorporatePinRequired, + '+CME ERROR: 47' : CorporatePukRequired, + '+CME ERROR: 48' : HiddenKeyRequired, + '+CME ERROR: 49' : EapMethodNotSupported, + '+CME ERROR: 50' : IncorrectParams, + '+CME ERROR: 100' : Unknown, + '+CME ERROR: 103' : GprsIllegalMs, + '+CME ERROR: 106' : GprsIllegalMe, + '+CME ERROR: 107' : GprsServiceNotAllowed, + '+CME ERROR: 111' : GprsPlmnNotAllowed, + '+CME ERROR: 112' : GprsLocationNotAllowed, + '+CME ERROR: 113' : GprsRoamingNotAllowed, + '+CME ERROR: 132' : GprsOptionNotSupported, + '+CME ERROR: 133' : GprsNotSubscribed, + '+CME ERROR: 134' : GprsOutOfOrder, + '+CME ERROR: 148' : GprsPdpAuthFailure, + '+CME ERROR: 149' : GprsUnspecified, + '+CME ERROR: 150' : GprsInvalidClass, + # not implemented on ModemManager (yet) + '+CME ERROR: 134' : ServiceTemporarilyOutOfOrder, + '+CME ERROR: 261' : UnknownSubscriber, + '+CME ERROR: 262' : ServiceNotInUse, + '+CME ERROR: 264' : UnknownNetworkMessage, + '+CME ERROR: 65281' : CallStateError, + + # CMS Errors + '+CMS ERROR: 300' : CMSError300, + '+CMS ERROR: 301' : CMSError301, + '+CMS ERROR: 302' : CMSError302, + '+CMS ERROR: 303' : CMSError303, + '+CMS ERROR: 304' : CMSError304, + '+CMS ERROR: 305' : CMSError305, + '+CMS ERROR: 310' : CMSError310, + '+CMS ERROR: 311' : CMSError311, + '+CMS ERROR: 313' : CMSError313, + '+CMS ERROR: 314' : CMSError314, + '+CMS ERROR: 315' : CMSError315, + '+CMS ERROR: 320' : CMSError320, + '+CMS ERROR: 321' : CMSError321, + '+CMS ERROR: 322' : CMSError322, + '+CMS ERROR: 330' : CMSError330, + '+CMS ERROR: 331' : CMSError331, + '+CMS ERROR: 500' : CMSError500, + + # USER GARBAGE ERRORS + 'INPUT VALUE IS OUT OF RANGE' : InputValueError, +} + +def extract_error(s): + """ + Scans ``s`` looking for AT Errors + + Returns a tuple with the exception, error and the match + """ + try: + match = ERROR_REGEXP.search(s) + if match: + try: + error = match.group('error') + exception = ERROR_DICT[error] + return exception, error, match + except KeyError, e: + log.err(e, "%r didn't map to any of my keys" % error) + + except AttributeError: + return None + +def error_to_human(e): + """Returns a human error out of ``e``""" + name = e.get_dbus_name().split('.')[-1] + return EXCEPT_TO_HUMAN[name] + +EXCEPT_TO_HUMAN = { + 'PhoneFailure' : "Phone failure", + 'NoConnection' : "No Connection to phone", + 'LinkReserved' : "Phone-adaptor link reserved", + 'OperationNotAllowed' : "Operation not allowed", + 'OperationNotSupported' : "Operation not supported", + 'PhSimPinRequired' : "PH-SIM PIN required", + 'PhFSimPinRequired' : "PH-FSIM PIN required", + 'PhFSimPukRequired' : "PH-FSIM PUK required", + 'SimNotInserted' : "SIM not inserted", + 'SimPinRequired' : "SIM PIN required", + 'SimPukRequired' : "SIM PUK required", + 'SimFailure' : "SIM failure", + 'SimBusy' : "SIM busy", + 'SimWrong' : "SIM wrong", + 'IncorrectPassword' : "Incorrect password", + 'SimPin2Required' : "SIM PIN2 required", + 'SimPuk2Required' : "SIM PUK2 required", + 'MemoryFull' : "Memory full", + 'InvalidIndex' : "Invalid index", + 'NotFound' : "Not found", + 'MemoryFailure' : "Memory failure", + 'TextTooLong' : "Text string too long", + 'InvalidChars' : "Invalid characters in text string", + 'DialStringTooLong' : "Dial string too long", + 'InvalidDialString' : "Invalid dial string", + 'NoNetwork' : "No network service", + 'NetworkTimeout' : "Network timeout", + 'NetworkNotAllowed' : "Network not allowed - Emergency calls only", + 'NetworkPinRequired' : "Network personalization PIN required", + 'NetworkPukRequired' : "Network personalization PUK required", + 'NetworkSubsetPinRequired' : "Network subset personalization PIN required", + 'NetworkSubsetPukRequired' : "Network subset personalization PUK required", + 'ServicePinRequired' : "Service provider personalization PIN required", + 'ServicePukRequired' : "Service provider personalization PUK required", + 'CorporatePinRequired' : "Corporate personalization PIN required", + 'CorporatePukRequired' : "Corporate personalization PUK required", + 'HiddenKeyRequired' : "Hidden key required", + 'EapMethodNotSupported' : "EAP method not supported", + 'IncorrectParams' : "Incorrect parameters", + 'Unknown' : "Unknown error", + 'GprsIllegalMs' : "Illegal MS", + 'GprsIllegalMe' : "Illegal ME", + 'GprsServiceNotAllowed' : "GPRS services not allowed", + 'GprsPlmnNotAllowed' : "PLMN not allowed", + 'GprsLocationNotAllowed' : "Location are not allowed", + 'GprsRoamingNotAllowed' : "Roaming not allowed in this location area", + 'GprsOptionNotSupported' : "Service option not supported", + 'GprsNotSuscribed' : "Requested service option not suscribed", + 'GprsOutOfOrder' : "Service temporarily out of order", + 'GprsPdpAuthFailure' : "PDP authentication failure", + 'GprsUnspecified' : "Unspecified GPRS error", + 'GprsInvalidClass' : "Invalid mobile class", + + # Wader own errors + 'NetworkRegistrationError' : "Could not register with home network", +} + diff --git a/wader/common/command.py b/wader/common/command.py new file mode 100644 index 0000000..e1d75e0 --- /dev/null +++ b/wader/common/command.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""AT Commands related classes and help functions""" + +import re + +from twisted.internet import defer + +from wader.common.aterrors import ERROR_REGEXP + +OK_REGEXP = re.compile("\r\n(?POK)\r\n") + +def build_cmd_dict(extract=OK_REGEXP, end=OK_REGEXP, error=ERROR_REGEXP): + """Returns a dictionary ready to be used in ``CMD_DICT``""" + return dict(extract=extract, end=end, error=error) + +def get_cmd_dict_copy(): + """ + Returns a copy of the ``CMD_DICT`` dictionary + + Use this instead of importing it directly as you may forget to copy() it + """ + return CMD_DICT.copy() + +CMD_DICT = { + + 'add_contact' : build_cmd_dict(), + + 'add_sms' : build_cmd_dict(re.compile('\r\n\+CMGW:\s(?P\d+)\r\n')), + + 'change_pin' : build_cmd_dict(), + + 'check_pin' : build_cmd_dict(re.compile(r""" + \r\n + \+CPIN:\s + (?P + READY | + SIM\sPIN2? | + SIM\sPUK2? + ) + \r\n""", re.X)), + + 'delete_contact' : build_cmd_dict(), + + 'delete_sms' : build_cmd_dict(), + + 'disable_echo' : build_cmd_dict(), + + 'enable_echo' : build_cmd_dict(), + + 'enable_radio' : build_cmd_dict(), + + 'enable_pin' : build_cmd_dict(), + + 'find_contacts' : build_cmd_dict(re.compile(r""" + \r\n + \+CPBF:\s + (?P\d+), + "(?P\+?\d+)", + (?P\d+), + \"(?P.*)\" + """, re.X)), + + 'get_apns' : build_cmd_dict(re.compile(r""" + \r\n + \+CGDCONT:\s + (?P\d), + "IP", + "(?P.*)", + "(?P.*)", + \d,\d""", re.X)), + + 'get_charsets': build_cmd_dict(re.compile('"(?P.*?)",?')), + + 'get_contact_by_index' : build_cmd_dict(re.compile(r""" + \r\n + \+CPBR:\s(?P\d+), + "(?P\+?\d+)", + (?P\d+), + "(?P.*)" + \r\n""", re.X)), + + 'get_contacts' : build_cmd_dict( + end=re.compile('(\r\n)?\r\n(OK)\r\n'), + extract=re.compile(r""" + \r\n + \+CPBR:\s(?P\d+), + "(?P\+?\d+)", + (?P\d+), + "(?P.*)" + """, re.X)), + + 'get_card_version' : build_cmd_dict(re.compile( + '\r\n(\+C?GMR:)?(?P.*)\r\n\r\nOK\r\n')), + + 'get_card_model' : build_cmd_dict(re.compile( + '\r\n(?P.*)\r\n\r\nOK\r\n')), + + 'get_charset': build_cmd_dict(re.compile( + '\r\n\+CSCS:\s"(?P.*)"\r\n')), + + 'get_esn': build_cmd_dict(re.compile('\r\n\+ESN:\s"(?P.*)"\r\n')), + + 'get_manufacturer_name': build_cmd_dict(re.compile( + '\r\n(?P.*)\r\n\r\nOK\r\n')), + + 'get_imei' : build_cmd_dict(re.compile("\r\n(?P\d+)\r\n")), + + 'get_imsi' : build_cmd_dict(re.compile('\r\n(?P\d+)\r\n')), + + 'get_netreg_status' : build_cmd_dict(re.compile(r""" + \r\n + \+CREG:\s + (?P\d),(?P\d+) + \r\n + """, re.X)), + + 'get_network_info' : build_cmd_dict(re.compile(r""" + \r\n + \+COPS:\s+ + (\d,\d, # or followed by num,num,str,num + "(?P[^"]*)", + (?P\d) + |(?P\d) + ) # end of group + \r\n""", re.X)), + + 'get_network_names' : build_cmd_dict(re.compile(r""" + \( + (?P\d+), + "(?P[^"]*)", + "(?P[^"]*)", + "(?P\d+)", + (?P\d) + \),?""", re.X)), + + 'get_signal_quality' : build_cmd_dict(re.compile(r""" + \r\n + \+CSQ:\s(?P\d+),(?P\d+) + \r\n""", re.X)), + + 'get_sms_format' : build_cmd_dict( + re.compile('\r\n\+CMGF:\s(?P\d)\r\n')), + + 'get_phonebook_size' : build_cmd_dict(re.compile(r""" + \r\n + \+CPBR:\s + \(\d\-(?P\d+)\),\d+,\d+ + \r\n""", re.X)), + + 'get_pin_status' : build_cmd_dict( + re.compile('\r\n\+CLCK:\s(?P\d)\r\n')), + + 'get_radio_status' : build_cmd_dict( + re.compile("\r\n\+CFUN:\s?(?P\d)\r\n")), + + 'get_roaming_ids' : build_cmd_dict(re.compile(r""" + \r\n + \+CPOL:\s(?P\d+), + (?P\d), + "(?P\d+)" + """, re.X)), + + 'get_sms' : build_cmd_dict(re.compile(r""" + \r\n + \+CMGL:\s + (?P\d+), + (?P\d),,\d+ + \r\n(?P\w+)""", re.X)), + + 'get_sms_by_index' : build_cmd_dict(re.compile(r""" + \r\n + \+CMGR:\s + (?P\d),, + \d+\r\n + (?P\w+) + \r\n""", re.X)), + + 'get_smsc' : build_cmd_dict(re.compile( + '\r\n\+CSCA:\s"(?P.*)",\d+\r\n')), + + 'hso_authenticate' : build_cmd_dict(), + + 'hso_get_ip4_config': build_cmd_dict(re.compile(r""" + \r\n + _OWANDATA:\s + (?P\d),\s + (?P.*),\s + (?P.*),\s + (?P.*),\s + (?P.*),\s + (?P.*),\s + (?P.*),\s + (?P\d+) + \r\r\n""", re.X)), + + 'register_with_netid' : build_cmd_dict(), + + 'reset_settings' : build_cmd_dict(), + + 'save_sms' : build_cmd_dict(re.compile( + '\r\n\r\n\+CMGW:\s(?P\d+)\r\n')), + + 'send_at' : build_cmd_dict(), + + 'send_sms' : build_cmd_dict(re.compile( + '\r\n\r\n\+CMGS:\s(?P\d+)\r\n')), + + 'send_sms_from_storage' : build_cmd_dict(re.compile( + '\r\n\+CMSS:\s(?P\d+)\r\n')), + + 'send_pin' : build_cmd_dict(), + + 'send_puk' : build_cmd_dict(), + + 'set_apn' : build_cmd_dict(), + + 'set_charset' : build_cmd_dict(), + + 'set_netreg_notification' : build_cmd_dict(), + + 'set_network_info_format' : build_cmd_dict(), + + 'set_sms_indication' : build_cmd_dict(), + + 'set_sms_format' : build_cmd_dict(), + + 'set_smsc' : build_cmd_dict(), +} + + +class ATCmd(object): + """I encapsulate all the data related to an AT command""" + def __init__(self, cmd, name=None, eol='\r\n'): + self.cmd = cmd + self.name = name + self.eol = eol + # Some commands like sending a sms require an special handling this + # is because we have to wait till we receive a prompt like '\r\n> ' + # if splitcmd is set, the second part will be send 0.1 seconds later + self.splitcmd = None + # command's deferred + self.deferred = defer.Deferred() + self.timeout = 15 # default timeout + self.call_id = None # DelayedCall reference + + def __repr__(self): + args = (self.name, self.get_cmd(), self.timeout) + return "" % args + + def get_cmd(self): + """Returns the raw AT command plus EOL""" + cmd = self.cmd + self.eol + return str(cmd) + diff --git a/wader/common/config.py b/wader/common/config.py new file mode 100644 index 0000000..a4aed8d --- /dev/null +++ b/wader/common/config.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +GConf-powered config + +This module lives in wader.common so it can be used from applications +that depend on wader-core. Do not try to use it in the core as it will +fail. +""" + +from os.path import join + +from wader.common.consts import APP_SLUG_NAME +from wader.common._gconf import GConfHelper + +CONF_PATH = '/apps/%s' % APP_SLUG_NAME + +DEFAULT_KEYS = ['plugins', 'test'] + +class WaderConfig(GConfHelper): + """I manage Wader config""" + + def __init__(self, keys=DEFAULT_KEYS, base_path=CONF_PATH): + # despite the fact that having default mutable types as + # argument in python, keys will never be modified, only + # read, so we are safe using it this way. + super(WaderConfig, self).__init__() + self.keys = keys + self.base_path = base_path + + def get(self, section, option, default=None): + """ + Returns the value at ``section/option`` + + Will return ``default`` if undefined + """ + value = self.client.get(join(self.base_path, section, option)) + if not value: + return (default if default is not None else "") + + return self.get_value(value) + + def set(self, section, option, value): + """Sets ``value`` at ``section/option``""" + path = join(self.base_path, section, option) + self.set_value(path, value) + + +config = WaderConfig() + diff --git a/wader/common/consts.py b/wader/common/consts.py new file mode 100644 index 0000000..5ad68b5 --- /dev/null +++ b/wader/common/consts.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Wader global variables""" + +from os.path import join + +from dbus import UInt32 + +from wader.common.utils import revert_dict + +# app name +APP_NAME = 'Wader' +APP_SLUG_NAME = 'wader-core' +APP_VERSION = '0.3.6' + +# DBus stuff +WADER_SERVICE = 'org.freedesktop.ModemManager' +WADER_OBJPATH = '/org/freedesktop/ModemManager' +WADER_INTFACE = 'org.freedesktop.ModemManager' +PROPS_INTFACE = 'org.freedesktop.DBus.Properties' + +WADER_DIALUP_INTFACE = 'org.freedesktop.ModemManager.Dialup' +WADER_DIALUP_SERVICE = 'org.freedesktop.ModemManager.Dialup' +WADER_DIALUP_OBJECT = '/org/freedesktop/ModemManager/DialupManager' +WADER_DIALUP_BASE = '/org/freedesktop/ModemManager/Connections/%d' +WADER_PROFILES_SERVICE = 'org.freedesktop.ModemManager.Profiles' +WADER_PROFILES_OBJPATH = '/org/freedesktop/ModemManager/Profiles' +WADER_PROFILES_INTFACE = WADER_PROFILES_SERVICE +WADER_KEYRING_SERVICE = 'org.freedesktop.ModemManager.Keyring' +WADER_KEYRING_OBJPATH = '/org/freedesktop/ModemManager/Keyring' +WADER_KEYRING_INTFACE = WADER_KEYRING_SERVICE + +MDM_INTFACE = 'org.freedesktop.ModemManager.Modem' +SPL_INTFACE = 'org.freedesktop.ModemManager.Modem.Simple' +SMS_INTFACE = 'org.freedesktop.ModemManager.Modem.Gsm.SMS' +CTS_INTFACE = 'org.freedesktop.ModemManager.Modem.Gsm.Contacts' +NET_INTFACE = 'org.freedesktop.ModemManager.Modem.Gsm.Network' +CRD_INTFACE = 'org.freedesktop.ModemManager.Modem.Gsm.Card' +HSO_INTFACE = 'org.freedesktop.ModemManager.Modem.Gsm.Hso' + +NM_SERVICE = 'org.freedesktop.NetworkManager' +NM_OBJPATH = '/org/freedesktop/NetworkManager' +NM_INTFACE = 'org.freedesktop.NetworkManager' +NM_GSM_INTFACE = NM_INTFACE + '.Device.Gsm' + +NM_USER_SETTINGS = 'org.freedesktop.NetworkManagerUserSettings' +NM_SYSTEM_SETTINGS = 'org.freedesktop.NetworkManagerSettings' +NM_SYSTEM_SETTINGS_OBJ = '/org/freedesktop/NetworkManagerSettings' +NM_SYSTEM_SETTINGS_CONNECTION = NM_SYSTEM_SETTINGS + '.Connection' +NM_SYSTEM_SETTINGS_SECRETS = NM_SYSTEM_SETTINGS_CONNECTION + '.Secrets' + +GCONF_PROFILES_BASE = '/system/networking/connections' + +STATUS_IDLE, STATUS_HOME, STATUS_SEARCHING = 0, 1, 2 +STATUS_DENIED, STATUS_UNKNOWN, STATUS_ROAMING = 3, 4, 5 + +NM_CONNECTED, NM_DISCONNECTED = 8, 3 + +NM_PASSWD = 'passwd' # NM_SETTINGS_GSM_PASSWORD + +MM_MODEM_TYPE = { + UInt32(1) : 'GSM', + UInt32(2) : 'CDMA', +} + +MM_IP_METHOD_PPP = UInt32(0) +MM_IP_METHOD_STATIC = UInt32(1) +MM_IP_METHOD_DHCP = UInt32(2) + +MM_NETWORK_MODE_ANY = 0 +MM_NETWORK_MODE_GPRS = 1 +MM_NETWORK_MODE_EDGE = 2 +MM_NETWORK_MODE_UMTS = 3 +MM_NETWORK_MODE_HSDPA = 4 +MM_NETWORK_MODE_2G_PREFERRED = 5 +MM_NETWORK_MODE_3G_PREFERRED = 6 +MM_NETWORK_MODE_2G_ONLY = 7 +MM_NETWORK_MODE_3G_ONLY = 8 +MM_NETWORK_MODE_HSUPA = 9 +MM_NETWORK_MODE_HSPA = 10 + +MM_NETWORK_MODE_LAST = MM_NETWORK_MODE_HSPA + +MM_NETWORK_BAND_EGSM = 1 # 900 MHz +MM_NETWORK_BAND_DCS = 2 # 1800 MHz +MM_NETWORK_BAND_PCS = 4 # 1900 MHz +MM_NETWORK_BAND_G850 = 8 # 850 MHz +MM_NETWORK_BAND_U2100 = 16 # WCDMA 2100 MHz (Class I) +MM_NETWORK_BAND_U1700 = 32 # WCDMA 3GPP UMTS1800 MHz (Class III) +MM_NETWORK_BAND_17IV = 64 # WCDMA 3GPP AWS 1700/2100 MHz (Class IV) +MM_NETWORK_BAND_U800 = 128 # WCDMA 3GPP UMTS800 MHz (Class VI) +MM_NETWORK_BAND_U850 = 256 # WCDMA 3GPP UMTS850 MHz (Class V) +MM_NETWORK_BAND_U900 = 512 # WCDMA 3GPP UMTS900 MHz (Class VIII) +MM_NETWORK_BAND_U17IX = 1024 # WCDMA 3GPP UMTS MHz (Class IX) +MM_NETWORK_BAND_U1900 = 2048 # WCDMA 3GPP UMTS MHz (Class IX) +MM_NETWORK_BAND_ANY = 65535 + +MM_NETWORK_BAND_LAST = MM_NETWORK_BAND_U1900 + +MM_MODEM_TYPE_REV = revert_dict(MM_MODEM_TYPE) + +MM_IP_METHOD_PPP = UInt32(0) +MM_IP_METHOD_STATIC = UInt32(1) +MM_IP_METHOD_DHCP = UInt32(2) + +MM_SYSTEM_SETTINGS_PATH = '/org/freedesktop/ModemManager/Settings' + +DATA_DIR = join('/usr', 'share', '%s' % APP_SLUG_NAME) +WADER_DOC = join('/usr', 'share', 'doc', '%s' % APP_SLUG_NAME, 'guide') + +# paths +RESOURCES_DIR = join(DATA_DIR, 'resources') +TEMPLATES_DIR = join(RESOURCES_DIR, 'config') +EXTRA_DIR = join(RESOURCES_DIR, 'extra') + +# network database +NETWORKS_DB = join(DATA_DIR, 'networks.db') + +# TEMPLATES +WVTEMPLATE = join(TEMPLATES_DIR, 'wvdial.conf.tpl') + +# plugins consts +PLUGINS_DIR = join(DATA_DIR, 'plugins') +PLUGINS_DIR = [PLUGINS_DIR, join(PLUGINS_DIR, 'oses'), + join(PLUGINS_DIR, 'devices')] + +# static dns stuff +WADER_DNS_LOCK = join('/tmp', 'wader-conn.lock') + +# wader-core-ctl stuff +PID_PATH = '/var/run/wader.pid' +LOG_PATH = '/var/log/wader.log' diff --git a/wader/common/contact.py b/wader/common/contact.py new file mode 100644 index 0000000..89c2b2f --- /dev/null +++ b/wader/common/contact.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Contact related classes and utilities""" + +from zope.interface import implements + +from wader.common.encoding import to_u +from wader.common.interfaces import IContact + +class Contact(object): + """I am a Contact on Wader""" + implements(IContact) + + def __init__(self, name, number, index=None): + super(Contact, self).__init__() + self.name = to_u(name) + self.number = to_u(number) + self.index = index + + def __repr__(self): + if self.index: + args = (self.index, self.name, self.number) + return '' % args + + return '' % (self.name, self.number) + + __str__ = __repr__ + + def __eq__(self, c): + if self.index is not None and c.index is not None: + return self.index == c.index + + return self.name == c.name and self.number == c.number + + def __ne__(self, c): + return not self.__eq__(c) + + def to_csv(self): + """See :meth:`wader.common.interfaces.IContact.to_csv`""" + name = '"%s"' % self.name + number = '"%s"' % self.number + return [name, number] + diff --git a/wader/common/daemon.py b/wader/common/daemon.py new file mode 100644 index 0000000..ae910b2 --- /dev/null +++ b/wader/common/daemon.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Daemons for Wader""" + +from twisted.internet.task import LoopingCall +from twisted.python import log + +from wader.common.netspeed import NetworkSpeed +from wader.common.utils import rssi_to_percentage +import wader.common.signals as S + +class WaderDaemon(object): + """ + I represent a Daemon in Wader + + A Daemon is an entity that performs a repetitive action, like polling + signal quality from the datacard. A Daemon will emit DBus signals as if + the device itself had emitted them. + """ + def __init__(self, frequency, device): + super(WaderDaemon, self).__init__() + self.frequency = frequency + self.device = device + self.loop = None + + def __repr__(self): + return self.__class__.__name__ + + def start(self): + """Starts the Daemon""" + log.msg("daemon %s started..." % self.__class__.__name__) + if not self.loop or not self.loop.running: + self.loop = LoopingCall(self.function) + self.loop.start(self.frequency) + + args = (self.__class__.__name__, 'function', self.frequency) + log.msg("executing %s.%s every %d seconds" % args) + + def stop(self): + """Stops the Daemon""" + if self.loop.running: + cname = self.__class__.__name__ + log.msg("daemon %s stopped..." % cname) + self.loop.stop() + + def function(self): + """Function that will be called periodically""" + raise NotImplementedError() + + +class SignalQualityDaemon(WaderDaemon): + """I emit SIG_RSSI UnsolicitedNotifications""" + + def function(self): + """Executes `get_signal_quality` periodically""" + d = self.device.sconn.get_signal_quality() + d.addCallback(rssi_to_percentage) + d.addCallback(lambda rssi: self.device.exporter.SignalQuality(rssi)) + + +class NetworkSpeedDaemon(WaderDaemon): + """I emit SIG_SPEED UnsolicitedNotifications""" + def __init__(self, frequency, device): + super(NetworkSpeedDaemon, self).__init__(frequency, device) + self.netspeed = NetworkSpeed() + + def start(self): + """Starts the network speed measurement process""" + self.netspeed.start() + self.loop = LoopingCall(self.function) + self.loop.start(self.frequency) + + def stop(self): + """Stops the network speed measurement process""" + self.loop.stop() + self.netspeed.stop() + + def function(self): + """Emits `SpeedChanged` signals every `self.frequency`""" + up, down = self.netspeed['up'], self.netspeed['down'] + self.device.exporter.SpeedChanged(up, down) + + +class NetworkRegistrationDaemon(WaderDaemon): + """ + I monitor several network registration parameters + + I cache, compare and emit if different from previous reading + """ + + def __init__(self, frequency, device): + super(NetworkRegistrationDaemon, self).__init__(frequency, device) + self.reading = None + + def function(self): + d = self.device.sconn.get_netreg_info() + d.addCallback(self.compare_and_emit_if_different) + + def compare_and_emit_if_different(self, info): + """ + Compares ``info`` with previously cached value + + If they are different it will emit `RegistrationInfo` signal + """ + if not self.reading: + self.reading = info + else: + if self.reading == info: + # nothing has changed + return + + self.device.exporter.RegistrationInfo(*info) + + +class WaderDaemonCollection(object): + """ + I am a collection of Daemons + + I provide some methods to manage the collection. + """ + def __init__(self): + self.daemons = {} + self.running = False + + def append_daemon(self, name, daemon): + """Adds ``daemon`` to the collection identified by ``name``""" + self.daemons[name] = daemon + + def has_daemon(self, name): + """Returns True if daemon ``name`` exists""" + return name in self.daemons + + def remove_daemon(self, name): + """Removes daemon with ``name``""" + del self.daemons[name] + + def start_daemons(self, arg=None): + """Starts all daemons""" + for daemon in self.daemons.values(): + daemon.start() + + self.running = True + + def stop_daemon(self, name): + """Stops daemon identified by ``name``""" + try: + self.daemons[name].stop() + except KeyError: + raise + + def stop_daemons(self): + """Stops all daemons""" + for daemon in self.daemons.values(): + daemon.stop() + + self.running = False + + +def build_daemon_collection(device): + """Returns a :class:`WaderServiceCollection` customized for ``device``""" + collection = WaderDaemonCollection() + + if device.ports.has_two(): + # check capabilities + if S.SIG_RSSI not in device.custom.device_capabilities: + # device doesn't sends unsolicited notifications about RSSI + # changes, we will have to monitor it ourselves every 15s + freq = 15 + + daemon = SignalQualityDaemon(freq, device) + collection.append_daemon('signal', daemon) + + else: + # device with just one port will never be able to send us + # unsolicited notifications, we'll have to fake 'em + daemon = SignalQualityDaemon(15, device) + collection.append_daemon('signal', daemon) + + # daemons to be used regardless of ports or capabilities + daemon = NetworkRegistrationDaemon(120, device) + collection.append_daemon('netreg', daemon) + + return collection + diff --git a/wader/common/dialer.py b/wader/common/dialer.py new file mode 100644 index 0000000..7e48f91 --- /dev/null +++ b/wader/common/dialer.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Dialer module abstracts the differences between dialers on different OSes""" + +import dbus +from dbus.service import Object, BusName, method, signal +from zope.interface import implements +from twisted.internet import defer, reactor +from twisted.python import log + +from wader.common._dbus import DBusExporterHelper +import wader.common.consts as consts +from wader.common.interfaces import IDialer +from wader.common.oal import osobj +from wader.common.runtime import nm07_present +from wader.common.utils import convert_int_to_ip + +CONFIG_DELAY = 3 + +class DialerConf(object): + """I contain all the necessary information to connect to Internet""" + uuid = "" + apn = None + username = None + password = None + pin = None + connection = None + band = None + network_type = None + autoconnect = False + staticdns = False + dns1 = None + dns2 = None + + def __init__(self, opath): + super(DialerConf, self).__init__() + self.opath = opath + self._from_dbus_path(opath) + + def __repr__(self): + msg = '' + args = (self.apn, self.username, self.password) + return msg % args + + def __str__(self): + return self.__repr__() + + def _get_profile_secrets(self, profile): + tag, hints, ask = 'gsm', [consts.NM_PASSWD], False + resp = profile.GetSecrets(tag, hints, ask, + dbus_interface=consts.NM_SYSTEM_SETTINGS_SECRETS) + + if not resp: + # if we don't get secrets without asking, lets try asking + resp = profile.GetSecrets(tag, hints, True, + dbus_interface=consts.NM_SYSTEM_SETTINGS_SECRETS) + + if consts.NM_PASSWD in resp[tag]: + return resp[tag][consts.NM_PASSWD] + + def _from_dbus_path(self, opath): + profile = dbus.SystemBus().get_object(consts.WADER_PROFILES_SERVICE, + opath) + props = profile.GetSettings( + dbus_interface=consts.NM_SYSTEM_SETTINGS_CONNECTION) + + self.uuid = props['connection']['uuid'] + self.apn = props['gsm']['apn'] + try: + self.username = props['gsm']['username'] + except KeyError: + log.err("no username in profile, asumming '*' can be used") + self.username = '*' + + if 'autoconnect' in props['connection']: + self.autoconnect = props['connection']['autoconnect'] + if 'band' in props['gsm']: + self.band = props['gsm']['band'] + if 'network-type' in props['gsm']: + self.network_type = props['gsm']['network-type'] + + self.staticdns = props['ipv4']['ignore-auto-dns'] + if self.staticdns: + if len(props['ipv4']['dns']): + dns1 = props['ipv4']['dns'][0] + self.dns1 = convert_int_to_ip(dns1) + if len(props['ipv4']['dns']) > 1: + dns2 = props['ipv4']['dns'][1] + self.dns2 = convert_int_to_ip(dns2) + + # finally, get the secrets + try: + self.password = self._get_profile_secrets(profile) + except: + log.err() + else: + if not self.password: + if self.username != '*': + log.err("No password in profile, yet username is defined") + else: + log.err("no password in profile, asumming '*' can be used") + self.password = '*' + + +class Dialer(Object): + """ + Base dialer class + + Override me for new OSes + """ + implements(IDialer) + config = None + protocol = None + + def __init__(self, device, opath, ctrl=None): + self.bus = dbus.SystemBus() + name = BusName(consts.WADER_DIALUP_SERVICE, bus=self.bus) + super(Dialer, self).__init__(bus_name=name, object_path=opath) + self.opath = opath + self.device = device + self.ctrl = ctrl + + def configure(self, config): + """ + Configures ``self.device`` with ``config`` + + This method should perform any necessary actions to connect to + Internet like generating configuration files, modifying any necessary + files, etc. + + :param config: `DialerConf` instance + """ + + def connect(self): + """Connects to Internet""" + + def stop(self): + """Stops a hung connection attempt""" + + def disconnect(self): + """Disconnects from Internet""" + + @signal(dbus_interface=consts.WADER_DIALUP_INTFACE, signature='') + def Connected(self): + log.msg("emitting Connected signal") + + @signal(dbus_interface=consts.WADER_DIALUP_INTFACE, signature='') + def Disconnected(self): + log.msg("emitting Disconnected signal") + + @signal(dbus_interface=consts.WADER_DIALUP_INTFACE, signature='as') + def InvalidDNS(self, dns): + log.msg("emitting InvalidDNS(%s)" % dns) + + +class DialerManager(Object, DBusExporterHelper): + """ + I am responsible of all dial up operations + + I provide a uniform API to make data calls using different + dialers on heterogeneous operating systems. + """ + def __init__(self, ctrl): + self.bus = dbus.SystemBus() + name = BusName(consts.WADER_DIALUP_SERVICE, bus=self.bus) + super(DialerManager, self).__init__(bus_name=name, + object_path=consts.WADER_DIALUP_OBJECT) + self.index = 0 + self.dialers = {} + self.ctrl = ctrl + self._connect_to_signals() + + def _device_removed_cb(self, udi): + """Executed when a udi goes away""" + if udi in self.dialers: + log.msg("Device %s removed! deleting dialer instance" % udi) + try: + self.deactivate_connection(udi) + except KeyError: + pass + + def _connect_to_signals(self): + self.bus.add_signal_receiver(self._device_removed_cb, + "DeviceRemoved", + consts.WADER_INTFACE) + + def get_dialer(self, dev_opath, opath): + """ + Returns an instance of the dialer that will be used to connect + + :param dev_opath: DBus object path of the device to use + :param opath: DBus object path of the dialer + """ + from wader.common.dialers.wvdial import WVDialDialer + from wader.common.dialers.hsolink import HSODialer + from wader.common.dialers.nm_dialer import NMDialer + + device = self.ctrl.hm.clients[dev_opath] + if nm07_present: + dialer_klass = NMDialer + else: + # NM 0.6.X + dialer_klass = HSODialer if device.dialer == 'hso' else WVDialDialer + + return dialer_klass(device, opath, ctrl=self.ctrl) + + def get_next_opath(self): + """Returns the next free object path""" + self.index += 1 + return consts.WADER_DIALUP_BASE % self.index + + def configure_radio_parameters(self, device_path, conf): + """Configures ``device_path`` using ``conf``""" + if not all([conf.band, conf.network_type]): + return defer.succeed(True) + + plugin = self.ctrl.hm.clients[device_path] + + deferred = defer.Deferred() + + if conf.band is not None and conf.network_type is None: + d = plugin.sconn.set_band(conf.band) + elif conf.band is None and conf.network_type is not None: + d = plugin.sconn.set_network_mode(conf.network_type) + else: # conf.band != None and conf.network_type != None + d = plugin.sconn.set_band(conf.band) + d.addCallback(lambda _: + plugin.sconn.set_network_mode(conf.network_type)) + d.addCallback(lambda _: + reactor.callLater(CONFIG_DELAY, deferred.callback, True)) + + return deferred + + def activate_connection(self, profile_opath, device_opath): + """ + Start a connection with device ``device_opath`` using ``profile_opath`` + """ + deferred = defer.Deferred() + conf = DialerConf(profile_opath) + opath = self.get_next_opath() + dialer = self.get_dialer(device_opath, opath) + + def after_configuring_device_connect(): + self.dialers[opath] = dialer + + d = defer.maybeDeferred(dialer.configure, conf) + d.addCallback(lambda ign: dialer.connect()) + d.addErrback(log.err) + d.chainDeferred(deferred) + + if dialer.__class__.__name__ == 'NMDialer': + after_configuring_device_connect() + else: + d = self.configure_radio_parameters(device_opath, conf) + d.addCallback(lambda ign: reactor.callLater(CONFIG_DELAY, + after_configuring_device_connect)) + return deferred + + def deactivate_connection(self, device_path): + """Stops connection of device ``device_path``""" + if device_path in self.dialers: + dialer = self.dialers[device_path] + d = dialer.disconnect() + def unexport_dialer(path): + try: + dialer.remove_from_connection() + except LookupError, e: + log.err(e) + + return path + + d.addCallback(unexport_dialer) + return d + + raise KeyError("Dialup %s not handled" % device_path) + + def stop_connection(self, device_path): + """Stops connection attempt of device ``device_path``""" + if device_path not in self.dialers: + raise KeyError("Dialup %s not handled" % device_path) + + dialer = self.dialers[device_path] + return dialer.stop() + + def get_stats(self, device_path): + """Get the traffic statistics for device ``device_path``""" + dialer = self.dialers[device_path] + return osobj.get_iface_stats(dialer.iface) + + @method(consts.WADER_DIALUP_INTFACE, in_signature='oo', out_signature='o', + async_callbacks=('async_cb', 'async_eb')) + def ActivateConnection(self, profile_path, device_path, + async_cb, async_eb): + """See :meth:`DialerManager.activate_connection`""" + d = self.activate_connection(profile_path, device_path) + return self.add_callbacks(d, async_cb, async_eb) + + @method(consts.WADER_DIALUP_INTFACE, in_signature='o', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def DeactivateConnection(self, device_path, async_cb, async_eb): + """See :meth:`DialerManager.deactivate_connection`""" + d = self.deactivate_connection(device_path) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(consts.WADER_DIALUP_INTFACE, in_signature='o', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def StopConnection(self, device_path, async_cb, async_eb): + """See :meth:`DialerManager.stop_connection`""" + try: + d = self.stop_connection(device_path) + except KeyError: + d = defer.succeed(True) + + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(consts.WADER_DIALUP_INTFACE, + in_signature='o', out_signature='(uu)') + def GetStats(self, device_path): + """See :meth:`DialerManager.get_stats`""" + return self.get_stats(device_path) + diff --git a/wader/common/dialers/__init__.py b/wader/common/dialers/__init__.py new file mode 100644 index 0000000..a8b5283 --- /dev/null +++ b/wader/common/dialers/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Dialers for Wader +""" diff --git a/wader/common/dialers/hsolink.py b/wader/common/dialers/hsolink.py new file mode 100644 index 0000000..1cd9ddc --- /dev/null +++ b/wader/common/dialers/hsolink.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""HSOLink Dialer""" + +from twisted.internet import defer + +from wader.common.dialer import Dialer +from wader.common.oal import osobj + +class HSODialer(Dialer): + """Dialer for HSO devices""" + + def __init__(self, device, opath, **kwds): + super(HSODialer, self).__init__(device, opath, **kwds) + self.iface = 'hso0' + self.retry_call = None + self.should_stop = False + self.num_of_retries = 0 + + def configure(self, config): + d = self.device.sconn.set_apn(config.apn) + d.addCallback(lambda _: self.device.sconn.hso_authenticate( + config.username, config.password)) + return d + + def connect(self): + # start the connection + conn_id = self.device.sconn.state_dict['conn_id'] + self.device.sconn.send_at('AT_OWANCALL=%d,1,0' % conn_id) + # now get the IP4Config and set up device and routes + d = self.device.sconn.hso_get_ip4_config() + d.addCallback(self._get_ip4_config_cb) + d.addCallback(lambda _: self.Connected()) + d.addCallback(lambda _: self.opath) + return d + + def _get_ip4_config_cb(self, (ip, dns1, dns2, dns3)): + d = osobj.configure_iface(self.iface, ip, 'up') + d.addCallback(lambda _: osobj.add_default_route(self.iface)) + d.addCallback(lambda _: osobj.add_dns_info([dns1, dns2], self.iface)) + return d + + def disconnect(self): + conn_id = self.device.sconn.state_dict['conn_id'] + d = self.device.sconn.send_at('AT_OWANCALL=%d,0,0' % conn_id) + osobj.delete_default_route(self.iface) + osobj.delete_dns_info(None, self.iface) + osobj.configure_iface(self.iface, '', 'down') + d.addCallback(lambda _: self.Disconnected()) + return d + + def stop(self): + self.should_stop = True + if self.retry_call: + self.retry_call.cancel() + self.retry_call = None + + return defer.succeed(True) + diff --git a/wader/common/dialers/nm_dialer.py b/wader/common/dialers/nm_dialer.py new file mode 100644 index 0000000..f9dea99 --- /dev/null +++ b/wader/common/dialers/nm_dialer.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Dialer module that wraps NetworkManager's dialer""" + +import dbus + +from twisted.python import log +from twisted.internet.defer import Deferred +from wader.common.dialer import Dialer +from wader.common.consts import (NM_SERVICE, NM_INTFACE, NM_OBJPATH, + NM_GSM_INTFACE, NM_USER_SETTINGS, + NM_CONNECTED, NM_DISCONNECTED, + WADER_PROFILES_SERVICE, + WADER_PROFILES_INTFACE, + WADER_PROFILES_OBJPATH) + +class NMDialer(Dialer): + """I wrap NetworkManager's dialer""" + def __init__(self, device, opath, **kwds): + super(NMDialer, self).__init__(device, opath, **kwds) + + self.int = None + self.conn_obj = None + self.iface = 'ppp0' + + self.nm_opath = None + self.connect_deferred = None + self.disconnect_deferred = None + self.sm = [] + + def _cleanup(self): + # enable +CREG notifications afterwards + self.device.sconn.set_netreg_notification(1) + self.sm.remove() + self.sm = None + + def _on_properties_changed(self, changed): + if 'State' in changed: + if changed['State'] == NM_CONNECTED: + # emit the connected signal and send back the opath + # if the deferred is present + self.Connected() + if self.connect_deferred and not self.connect_deferred.called: + self.connect_deferred.callback(self.opath) + + elif changed['State'] == NM_DISCONNECTED: + self.Disconnected() + self._cleanup() + if (self.disconnect_deferred and + not self.disconnect_deferred.called): + self.disconnect_deferred.callback(self.conn_obj) + + def _setup_signals(self): + self.sm = self.bus.add_signal_receiver(self._on_properties_changed, + "PropertiesChanged", + path=self.device.udi, + dbus_interface=NM_GSM_INTFACE) + + def configure(self, config): + self._setup_signals() + # get the profile object and obtains its uuid + # get ProfileManager and translate the uuid to a NM object path + profiles = self.bus.get_object(WADER_PROFILES_SERVICE, + WADER_PROFILES_OBJPATH) + self.nm_opath = profiles.GetNMObjectPath(str(config.uuid), + dbus_interface=WADER_PROFILES_INTFACE) + # Disable +CREG notifications, otherwise NMDialer won't work + self.device.sconn.set_netreg_notification(0) + + def connect(self): + self.connect_deferred = Deferred() + obj = self.bus.get_object(NM_SERVICE, NM_OBJPATH) + self.int = dbus.Interface(obj, NM_INTFACE) + args = (NM_USER_SETTINGS, self.nm_opath, self.device.udi, '/') + log.msg("Connecting with:\n%s\n%s\n%s\n%s" % args) + try: + self.conn_obj = self.int.ActivateConnection(*args) + # the deferred will be callbacked as soon as we get a + # connectivity status change + return self.connect_deferred + except dbus.DBusException, e: + log.err(e) + self._cleanup() + + def stop(self): + self._cleanup() + return self.disconnect() + + def disconnect(self): + self.disconnect_deferred = Deferred() + self.int.DeactivateConnection(self.conn_obj) + # the deferred will be callbacked as soon as we get a + # connectivity status change + return self.disconnect_deferred + diff --git a/wader/common/dialers/wvdial.py b/wader/common/dialers/wvdial.py new file mode 100644 index 0000000..b49dc4e --- /dev/null +++ b/wader/common/dialers/wvdial.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""WvDial Dialer""" + +from cStringIO import StringIO +import os +import re +import shutil +from string import Template +import tempfile + +from twisted.python import log, procutils +from twisted.internet import utils, reactor, defer, protocol, error + +import wader.common.consts as consts +from wader.common.dialer import Dialer +from wader.common.utils import get_file_data, save_file, is_bogus_ip + +PAP_SECRETS = os.path.join('/etc', 'ppp', 'pap-secrets') +CHAP_SECRETS = os.path.join('/etc', 'ppp', 'chap-secrets') +WVDIAL_CONF = os.path.join('/etc', 'ppp', 'peers', 'wvdial') + +DEFAULT_TEMPLATE = """ +debug +noauth +name wvdial +replacedefaultroute +noipdefault +nomagic +usepeerdns +ipcp-accept-local +ipcp-accept-remote +nomp +noccp +nopredictor1 +novj +novjccomp +nobsdcomp""" + +PAP_TEMPLATE = DEFAULT_TEMPLATE + """ +refuse-chap +refuse-mschap +refuse-mschap-v2 +refuse-eap +""" + +CHAP_TEMPLATE = DEFAULT_TEMPLATE + """ +refuse-pap +""" + +TEMPLATES_DICT = { + 'default' : DEFAULT_TEMPLATE, + 'PAP' : PAP_TEMPLATE, + 'CHAP' : CHAP_TEMPLATE, +} + +### wvdial.conf stuff +def get_wvdial_conf_file(conf, serial_port): + """ + Returns the path of the generated wvdial.conf + + :param conf: `DialerConf` instance + :param serial_port: The port to use + :rtype: str + """ + text = _generate_wvdial_conf(conf, serial_port) + dirpath = tempfile.mkdtemp('', consts.APP_NAME, '/tmp') + path = tempfile.mkstemp('wvdial.conf', consts.APP_NAME, dirpath, True)[1] + save_file(path, text) + return path + +def _generate_wvdial_conf(conf, sport): + """ + Generates a specially crafted wvdial.conf with `conf` and `sport` + + :param conf: `DialerConf` instance + :param sport: The port to use + :rtype: str + """ + user = conf.username + passwd = conf.password + theapn = conf.apn + + # build template + data = StringIO(get_file_data(consts.WVTEMPLATE)) + template = Template(data.getvalue()) + data.close() + # return template + props = dict(serialport=sport, username=user, password=passwd, apn=theapn) + return template.substitute(props) + + +class WVDialDialer(Dialer): + """Dialer for WvDial""" + binary = 'wvdial' + + def __init__(self, device, opath, **kwds): + super(WVDialDialer, self).__init__(device, opath, **kwds) + try: + self.bin_path = procutils.which(self.binary)[0] + except IndexError: + self.bin_path = '/usr/bin/wvdial' + + self.backup_path = "" + self.conf = None + self.conf_path = "" + self.proto = None + self.iconn = None + self.iface = 'ppp0' + + def configure(self, config): + return defer.maybeDeferred(self._generate_config, config) + + def connect(self): + self.proto = WVDialProtocol(self) + args = [self.binary, '-C', self.conf_path, 'connect'] + self.iconn = reactor.spawnProcess(self.proto, args[0], args, env=None) + return self.proto.deferred + + def stop(self): + return self.disconnect() + + def disconnect(self): + # ignore the fact that we are gonna be disconnected + if not self.proto: + return defer.succeed(self.opath) + + self.proto.ignore_disconnect = True + + try: + self.proto.transport.signalProcess('KILL') + except error.ProcessExitedAlready: + return defer.succeed(self.opath) + + # just be damn sure that we're killing everything + if self.proto.pid: + try: + kill_path = procutils.which('kill')[0] + except IndexError: + kill_path = '/bin/kill' + + args = [kill_path, '-9', self.proto.pid] + d = utils.getProcessValue(args[0], args, env=None) + def disconnect_cb(error_code): + log.msg("wvdial: exit code %d" % error_code) + self._cleanup() + return defer.succeed(self.opath) + + d.addCallback(disconnect_cb) + d.addErrback(log.err) + return d + else: + self._cleanup() + return defer.succeed(self.opath) + + def _generate_config(self, conf): + # backup wvdial configuration + self.backup_path = self._backup_conf() + self.conf = conf + # generate wvdial.conf from template + port = self.device.ports.dport + self.conf_path = get_wvdial_conf_file(self.conf, port.path) + + def _cleanup(self, ignored=None): + """cleanup our traces""" + try: + path = os.path.dirname(self.conf_path) + os.unlink(self.conf_path) + os.rmdir(path) + except (IOError, OSError): + pass + + self._restore_conf() + + def _backup_conf(self): + path = tempfile.mkstemp('wvdial', consts.APP_NAME)[1] + try: + shutil.copy(WVDIAL_CONF, path) + # XXX: Handle PAP&CHAP + save_file(path, DEFAULT_TEMPLATE) + return path + except IOError: + return None + + def _restore_conf(self): + if self.backup_path: + shutil.copy(self.backup_path, WVDIAL_CONF) + os.unlink(self.backup_path) + + def _set_iface(self, iface): + self.iface = iface + if self.conf.staticdns: + from wader.common.oal import osobj + osobj.add_dns_info((self.conf.dns1, self.conf.dns2), iface) + + +CONNECTED_REGEXP = re.compile('Connected\.\.\.') +PPPD_PID_REGEXP = re.compile('Pid of pppd: (?P\d+)') +PPPD_IFACE_REGEXP = re.compile('Using interface (?Pppp\d+)') +MAX_ATTEMPTS_REGEXP = re.compile('Maximum Attempts Exceeded') +PPPD_DIED_REGEXP = re.compile('The PPP daemon has died') +DNS_REGEXP = re.compile(r""" + DNS\saddress + \s # beginning of the string + (?P # group named ip + (25[0-5]| # integer range 250-255 OR + 2[0-4][0-9]| # integer range 200-249 OR + [01]?[0-9][0-9]?) # any number < 200 + \. # matches '.' + (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) # repeat + \. # matches '.' + (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) # repeat + \. # matches '.' + (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) # repeat + ) # end of group + \b # end of the string + """, re.VERBOSE) + + +class WVDialProtocol(protocol.ProcessProtocol): + """ProcessProtocol for wvdial""" + + def __init__(self, dialer): + self.dialer = dialer + self.__connected = False + self.pid = None + self.number_of_inits = 0 + self.deferred = defer.Deferred() + self.ignore_disconnect = False + self.dns = [] + + def connectionMade(self): + self.transport.closeStdin() + + def outReceived(self, data): + log.msg("wvdial: sysout %s" % data) + + def errReceived(self, data): + """wvdial has this bad habit of using stderr for debug""" + log.msg("wvdial: %r" % data) + self._parse_output(data) + + def outConnectionLost(self): + log.msg('wvdial: pppd closed their stdout!') + + def errConnectionLost(self): + log.msg('wvdial: pppd closed their stderr.') + + def processEnded(self, status_object): + log.msg('wvdial: quitting') + if not self.__connected: + if not self.ignore_disconnect: + self.dialer.disconnect() + self.dialer.Disconnected() + + def _set_connected(self): + if self.__connected: + return + + self.__connected = True + self.dialer.Connected() + self.deferred.callback(self.dialer.opath) + + def _extract_iface(self, data): + m = PPPD_IFACE_REGEXP.search(data) + if m: + self.dialer._set_iface(m.group('iface')) + log.msg("wvdial: dialer interface %s" % self.dialer.iface) + + def _extract_dns_strings(self, data): + if self.__connected: + return + + for match in re.finditer(DNS_REGEXP, data): + dns_ip = match.group('ip') + self.dns.append(dns_ip) + + if len(self.dns) == 2: + self._set_connected() + + # check if they're valid DNS ips + if any(map(is_bogus_ip, self.dns)): + # the DNS assigned by the APN is probably invalid + # notify the user only if she didn't specify static DNS + self.dialer.InvalidDNS(self.dns) + + def _extract_connected(self, data): + if self.__connected: + return + + # extract pppd pid + pid = PPPD_PID_REGEXP.search(data) + if pid: + self.pid = pid.group('pid') + self.number_of_inits += 1 + + connected = CONNECTED_REGEXP.search(data) + if connected: + self._set_connected() + + def _extract_disconnected(self, data): + # more than three attempts + disconnected = MAX_ATTEMPTS_REGEXP.search(data) + # pppd died + pppd_died = PPPD_DIED_REGEXP.search(data) + # wvdial refuses to stop after three attempts? + + if disconnected or pppd_died or self.number_of_inits >= 3: + if not self.ignore_disconnect: + self.__connected = False + self.dialer.disconnect() + self.dialer.Disconnected() + + def _parse_output(self, data): + self._extract_iface(data) + self._extract_dns_strings(data) + if not self.__connected: + self._extract_connected(data) + else: + self._extract_disconnected(data) + diff --git a/wader/common/encoding.py b/wader/common/encoding.py new file mode 100644 index 0000000..ff5e140 --- /dev/null +++ b/wader/common/encoding.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Helper methods for dealing with encoded strings""" + +import codecs + +ucs2_encoder = codecs.getencoder("utf_16be") +ucs2_decoder = codecs.getdecoder("utf_16be") +hex_decoder = codecs.getdecoder("hex_codec") + +def pack_ucs2_bytes(s): + """ + Converts string ``s`` to UCS2 + + :rtype: str + """ + return "".join(["%02X" % ord(c) for c in ucs2_encoder(s)[0]]) + +def unpack_ucs2_bytes(s): + """ + Unpacks string ``s`` from UCS2 + + :rtype: unicode + """ + octets = [ord(c) for c in hex_decoder(s)[0]] + user_data = "".join(chr(o) for o in octets) + return ucs2_decoder(user_data)[0] + +def check_if_ucs2(s): + """ + Test whether ``s`` is a UCS2 encoded string + + :rtype: bool + """ + if isinstance(s, str) and s.startswith('00'): + try: + unpack_ucs2_bytes(s) + return True + except (UnicodeDecodeError, TypeError): + pass + + return False + +def from_u(s): + """ + Encodes ``s`` to utf-8 if its not already encoded + + :rtype: str + """ + return (s.encode('utf8') if isinstance(s, unicode) else s) + +def from_ucs2(s): + """ + Converts ``s`` from UCS2 if not already converted + + :rtype: str + """ + return (unpack_ucs2_bytes(s) if check_if_ucs2(s) else s) + +def to_u(s): + """ + Converts ``s`` to unicode if not already converted + + :rtype: unicode + """ + return (s if isinstance(s, unicode) else unicode(s, 'utf8')) + diff --git a/wader/common/exceptions.py b/wader/common/exceptions.py new file mode 100644 index 0000000..9900f9d --- /dev/null +++ b/wader/common/exceptions.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""All the exceptions in Wader""" + +class DeviceLockedError(Exception): + """ + Exception raised after an authentication mess ending up in a device locked + """ + +class LimitedServiceNetworkError(Exception): + """Exception raised when AT+COPS? replied 'Limited Service'""" + +class MalformedSMSError(Exception): + """Exception raised when an error is received decodifying a SMS""" + +class NetworkRegistrationError(Exception): + """ + Exception raised when an error occurred while registering with the network + """ + +class ProfileNotFoundError(Exception): + """Exception raised when a profile hasn't been found""" + +class UnknownPluginNameError(Exception): + """ + Exception raised when we don't have a plugin with the given remote name + """ + diff --git a/wader/common/exported.py b/wader/common/exported.py new file mode 100644 index 0000000..6654ec4 --- /dev/null +++ b/wader/common/exported.py @@ -0,0 +1,794 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008 Warp Networks S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +I export :class:`~wader.common.middleware.WCDMAWrapper` methods over DBus +""" +import dbus +from dbus.service import Object, BusName, method, signal + +from twisted.python import log + +from wader.common.consts import (SMS_INTFACE, CTS_INTFACE, NET_INTFACE, + CRD_INTFACE, MDM_INTFACE, WADER_SERVICE, + HSO_INTFACE, SPL_INTFACE, PROPS_INTFACE) +from wader.common.sms import sms_to_dict, dict_to_sms +from wader.common.contact import Contact +from wader.common._dbus import DBusExporterHelper +from wader.common.utils import convert_ip_to_int + +# welcome to the multiple inheritance madness! +# python-dbus currently lacks an "export_as" keyword for use cases like +# us. Where we have a main object with dozens of methods that we want to +# export over several interfaces under repeated names, such as: +# org.freedesktop.ModemManager.Contacts.List +# org.freedesktop.ModemManager.SMS.List + +# currently python-dbus requires you to create a new class and it will find +# the appropiated implementation through the MRO. But this leads to MH madness +# What we can do thou is rely on composition instead of MH for this one + + +class ModemExporter(Object, DBusExporterHelper): + """I export the org.freedesktop.ModemManager.Modem interface""" + + def __init__(self, device): + name = BusName(WADER_SERVICE, bus=dbus.SystemBus()) + super(ModemExporter, self).__init__(bus_name=name, + object_path=device.udi) + self.device = device + self.sconn = device.sconn + + @method(MDM_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Connect(self, number, async_cb, async_eb): + """ + Dials in the given number + + :param number: number to dial + """ + assert 'conn_id' in self.sconn.state_dict, "Did you call SetApn at all?" + assert len(number) == 4, "bad number: %s" % number + assert number[-1] == '#', "bad number: %s" % number + + num = "%s***%d#" % (str(number[:-1]), self.sconn.state_dict['conn_id']) + d = self.sconn.connect_to_internet(num) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(MDM_INTFACE, in_signature='', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Disconnect(self, async_cb, async_eb): + """Disconnects modem""" + d = self.sconn.disconnect_from_internet() + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(MDM_INTFACE, in_signature='b', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Enable(self, enable, async_cb, async_eb): + """ + Performs some initial setup in the device + + :param enable: whether device should be enabled or disabled + :type enable: bool + """ + d = self.sconn.enable_device(enable) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(PROPS_INTFACE, in_signature='ss', out_signature='v') + def Get(self, interface_name, _property): + """See org.freedesktop.DBus.Properties documentation""" + if interface_name in self.device.props: + if _property in self.device.props[interface_name]: + return self.device.props[interface_name][_property] + + raise ValueError("Unknown property %s" % _property) + + raise ValueError("Unknown interface %s" % interface_name) + + @method(PROPS_INTFACE, in_signature='s', out_signature='a{sv}') + def GetAll(self, interface_name): + """See org.freedesktop.DBus.Properties documentation""" + if interface_name in self.device.props: + return self.device.props[interface_name] + + @method(MDM_INTFACE, in_signature='', out_signature='(uuuu)', + async_callbacks=('async_cb', 'async_eb')) + def GetIP4Config(self, async_cb, async_eb): + """ + Requests the IP4 configuration from the device + + :rtype: tuple + """ + d = self.sconn.get_ip4_config() + d.addCallback(lambda reply: map(convert_ip_to_int, reply)) + return self.add_callbacks(d, async_cb, async_eb) + + @signal(dbus_interface=MDM_INTFACE, signature='o') + def DeviceEnabled(self, opath): + log.msg("emitting DeviceEnabled('%s')" % opath) + + +class SimpleExporter(ModemExporter): + """I export the org.freedesktop.ModemManager.Modem.Simple interface""" + + @method(SPL_INTFACE, in_signature='a{sv}', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Connect(self, settings, async_cb, async_eb): + """ + Connects with the given settings + + :type settings: dict + :param settings: documented in ModemManager spec + """ + assert 'number' in settings, "No number in %s" % settings + number = settings['number'] + assert len(number) == 4, "bad number: %s" % number + assert number[-1] == '#', "bad number: %s" % number + + d = self.sconn.connect_simple(settings) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(SPL_INTFACE, in_signature='', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Disconnect(self, async_cb, async_eb): + """Disconnects modem""" + d = self.sconn.disconnect_from_internet() + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(SPL_INTFACE, in_signature='', out_signature='a{sv}', + async_callbacks=('async_cb', 'async_eb')) + def GetStatus(self, async_cb, async_eb): + """ + Get the modem status + + :rtype: dict + """ + def get_simple_status_cb(status): + # by default it is converted to Int32 + for name in ['signal_quality', 'band']: + status[name] = dbus.UInt32(status[name]) + + return status + + d = self.sconn.get_simple_status() + d.addCallback(get_simple_status_cb) + return self.add_callbacks(d, async_cb, async_eb) + + +class CardExporter(SimpleExporter): + """I export the org.freedesktop.ModemManager.Modem.Gsm.Card methods""" + + @method(CRD_INTFACE, in_signature='ss', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def ChangePin(self, oldpin, newpin, async_cb, async_eb): + """ + Changes PIN from ``oldpin`` to ``newpin`` + + :param oldpin: The old PIN + :param newpin: The new PIN + """ + d = self.sconn.change_pin(oldpin, newpin) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def Check(self, async_cb, async_eb): + """ + Returns the SIM authentication state + + :raise ``SimPinRequired``: If PIN is required + :raise ``SimPukRequired``: If PUK is required + :raise ``SimPuk2Required``: If PUK2 is required + """ + d = self.sconn.check_pin() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='b', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def EnableEcho(self, enable, async_cb, async_eb): + """ + Enables or disables echo + + Enabling echo will leave your connection unusable as this + application assumes that it will be disabled + + :param enable: Whether echo should be disabled or not + """ + if enable: + d = self.sconn.enable_echo() + else: + d = self.sconn.disable_echo() + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='sb', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def EnablePin(self, pin, enable, async_cb, async_eb): + """ + Enables or disables PIN authentication + + :param pin: The PIN to use + :param enable: Whether PIN auth should be enabled or disabled + """ + d = self.sconn.enable_pin(pin, enable) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetCharset(self, async_cb, async_eb): + """Returns active charset""" + d = self.sconn.get_charset() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='as', + async_callbacks=('async_cb', 'async_eb')) + def GetCharsets(self, async_cb, async_eb): + """Returns the available charsets in SIM""" + d = self.sconn.get_charsets() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetImei(self, async_cb, async_eb): + """Returns the IMEI""" + d = self.sconn.get_imei() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetImsi(self, async_cb, async_eb): + """Returns the IMSI""" + d = self.sconn.get_imsi() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='(sss)', + async_callbacks=('async_cb', 'async_eb')) + def GetInfo(self, async_cb, async_eb): + """ + Returns the manufacturer, modem model and firmware version + + :rtype: tuple + """ + d = self.sconn.get_hardware_info() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetManufacturer(self, async_cb, async_eb): + """Returns the device manufacturer name""" + d = self.sconn.get_manufacturer_name() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetModel(self, async_cb, async_eb): + """Returns the device model""" + d = self.sconn.get_card_model() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetVersion(self, async_cb, async_eb): + """Returns the device firmware version""" + d = self.sconn.get_card_version() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def ResetSettings(self, async_cb, async_eb): + """Resets the stored settings in SIM""" + d = self.sconn.reset_settings() + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='s', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def SendATString(self, at_str, async_cb, async_eb): + """ + Sends an arbitrary AT command + + :param at_str: The AT command to be sent + """ + d = self.sconn.send_at(at_str) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SendPin(self, pin, async_cb, async_eb): + """ + Sends ``pin`` to authenticate with SIM + + :param pin: The PIN to authenticate with + """ + d = self.sconn.send_pin(pin) + # check_initted_device will check if a Enable call was + # interrupted because of PINNeededError and will continue + # if auth is successful + d.addCallback(self.sconn._check_initted_device) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='ss', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SendPuk(self, puk, pin, async_cb, async_eb): + """ + Sends ``puk`` and ``pin`` to authenticate with SIM + + :param puk: The PUK to authenticate with + :param pin: The PIN to authenticate with + """ + d = self.sconn.send_puk(puk, pin) + # check_initted_device will check if a Enable call was + # interrupted because of PUKNeededError and will continue + # if auth is successful + d.addCallback(self.sconn._check_initted_device) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CRD_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetCharset(self, charset, async_cb, async_eb): + """ + Sets the SIM charset to ``charset`` + + :param charset: The character set to use + """ + d = self.sconn.set_charset(charset.encode('utf8')) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + +class NetworkExporter(CardExporter): + """I export the org.freedesktop.ModemManager.Modem.Gsm.Network interface""" + + @method(NET_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetApns(self, async_cb, async_eb): + """Returns all the APNS stored in the system""" + d = self.sconn.get_apns() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def GetBand(self, async_cb, async_eb): + """Returns the currently used band""" + d = self.sconn.get_band() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='au', + async_callbacks=('async_cb', 'async_eb')) + def GetBands(self, async_cb, async_eb): + """ + Returns the supported bands + + :rtype: list + """ + d = self.sconn.get_bands() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='(ss)', + async_callbacks=('async_cb', 'async_eb')) + def GetInfo(self, async_cb, async_eb): + """Returns the current operator name and link type""" + d = self.sconn.get_network_info() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def GetNetworkMode(self, async_cb, async_eb): + """Returns the network mode""" + d = self.sconn.get_network_mode() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='(uss)', + async_callbacks=('async_cb', 'async_eb')) + def GetRegistrationInfo(self, async_cb, async_eb): + """ + Returns the network registration status and operator + + :rtype: tuple + """ + d = self.sconn.get_netreg_info() + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='as', + async_callbacks=('async_cb', 'async_eb')) + def GetRoamingIDs(self, async_cb, async_eb): + """Returns all the roaming IDs stored in the SIM""" + d = self.sconn.get_roaming_ids() + d.addCallback(lambda objs: [obj.netid for obj in objs]) + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='i', + async_callbacks=('async_cb', 'async_eb')) + def GetSignalQuality(self, async_cb, async_eb): + """Returns the signal quality""" + d = self.sconn.get_signal_quality() + d.addCallback(lambda rssi: (rssi * 100) / 31) + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='', out_signature='aa{ss}', + async_callbacks=('async_cb', 'async_eb')) + def Scan(self, async_cb, async_eb): + """Returns the basic information of the networks around""" + d = self.sconn.get_network_names() + def process_netnames(netobjs): + response = [] + for n in netobjs: + # status should be an int, but it appeared in the + # ModemManager spec first as a string and in order + # to not break existing software (it seems that + # nm-applet in OpenSuSe uses it) we decided not to + # change it for now. + net = { 'status' : str(n.stat), + 'operator-long' : n.long_name, + 'operator-short' : n.short_name, + 'operator-num' : n.netid } + response.append(net) + + return response + + d.addCallback(process_netnames) + return self.add_callbacks(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetApn(self, apn, async_cb, async_eb): + """ + Sets the APN to ``apn`` + + :param apn: The APN to use + """ + d = self.sconn.set_apn(apn) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='u', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetBand(self, band, async_cb, async_eb): + """ + Sets the band to ``band`` + + :param band: The band to use + :type band: int + """ + d = self.sconn.set_band(band) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='u', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetNetworkMode(self, mode, async_cb, async_eb): + """ + Sets the network mode to ``mode`` + + :param mode: The network mode to use + :type mode: int + """ + d = self.sconn.set_network_mode(mode) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='b', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetRegistrationNotification(self, active, async_cb, async_eb): + """ + Sets the network registration notifications + + :param active: Enable registration notifications + :type active: bool + """ + d = self.sconn.set_netreg_notification(int(active)) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='uu', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetInfoFormat(self, mode, _format, async_cb, async_eb): + """ + Sets the network info format + + :param mode: The network mode + :type mode: int + :param _format: The network format + :type _format: int + """ + d = self.sconn.set_network_info_format(mode, _format) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(NET_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Register(self, netid, async_cb, async_eb): + """ + Registers with ``netid`` + + If netid is an empty string it will try to register with the + home network or the first provider around whose MNC matches + with one of the response of +CPOL? + + :param netid: The network id to register with + :type netid: str + """ + d = self.sconn.register_with_netid(netid) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @signal(dbus_interface=NET_INTFACE, signature='uss') + def RegistrationInfo(self, status, operator_code, operator_name): + args = (status, operator_code, operator_name) + log.msg("emitting RegistrationInfo(%d, '%s', '%s')" % args) + + @signal(dbus_interface=NET_INTFACE, signature='u') + def NetworkMode(self, mode): + log.msg("emitting NetworkMode(%d)" % mode) + + @signal(dbus_interface=NET_INTFACE, signature='u') + def CregReceived(self, status): + log.msg("emitting CregReceived(%d)" % status) + + @signal(dbus_interface=NET_INTFACE, signature='u') + def SignalQuality(self, rssi): + log.msg("emitting SignalQuality(%d)" % rssi) + + @signal(dbus_interface=NET_INTFACE, signature='(ss)') + def SpeedChanged(self, (up, down)): + log.msg("emitting SpeedChanged('%s', '%s')" % (up, down)) + + +class SMSExporter(NetworkExporter): + """I export the org.freedesktop.ModemManager.Modem.Gsm.Sms interface""" + + @method(SMS_INTFACE, in_signature='u', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Delete(self, index, async_cb, async_eb): + """ + Deletes the SMS at ``index`` + + :param index: The SMS index + """ + d = self.sconn.delete_sms(index) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='u', out_signature='a{sv}', + async_callbacks=('async_cb', 'async_eb')) + def Get(self, index, async_cb, async_eb): + """ + Returns the SMS stored at ``index`` + + :param index: The SMS index + """ + d = self.sconn.get_sms_by_index(index) + d.addCallback(lambda sms: sms_to_dict(sms)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='', out_signature='s', + async_callbacks=('async_cb', 'async_eb')) + def GetSmsc(self, async_cb, async_eb): + """Returns the SMSC number stored in the SIM""" + d = self.sconn.get_smsc() + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def GetFormat(self, async_cb, async_eb): + """Returns 1 if SMS format is text and 0 if SMS format is PDU""" + d = self.sconn.get_sms_format() + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='', out_signature='aa{sv}', + async_callbacks=('async_cb', 'async_eb')) + def List(self, async_cb, async_eb): + """Returns all the SMS stored in SIM""" + d = self.sconn.get_sms() + d.addCallback(lambda messages: map(sms_to_dict, messages)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='a{sv}', out_signature='au', + async_callbacks=('async_cb', 'async_eb')) + def Save(self, sms, async_cb, async_eb): + """ + Save a SMS ``sms`` and returns the index + + :param sms: dictionary with the settings to use + :rtype: int + """ + d = self.sconn.save_sms(dict_to_sms(sms)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='a{sv}', out_signature='au', + async_callbacks=('async_cb', 'async_eb')) + def Send(self, sms, async_cb, async_eb): + """ + Sends SMS ``sms`` + + :param sms: dictionary with the settings to use + :rtype: list + """ + d = self.sconn.send_sms(dict_to_sms(sms)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='u', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def SendFromStorage(self, index, async_cb, async_eb): + """ + Sends the SMS stored at ``index`` and returns the new index + + :param index: The index of the stored SMS to be sent + :rtype: int + """ + d = self.sconn.send_sms_from_storage(index) + return self.add_callbacks(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='u', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetFormat(self, _format, async_cb, async_eb): + """Sets the SMS format""" + if _format not in [0, 1]: + async_eb(ValueError("Invalid SMS format %s" % repr(_format))) + + d = self.sconn.set_sms_format(_format) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='uuuuu', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetIndication(self, mode, mt, bm, ds, bfr, async_cb, async_eb): + """Sets the SMS indication""" + d = self.sconn.set_sms_indication(mode, mt, bm, ds, bfr) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(SMS_INTFACE, in_signature='s', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def SetSmsc(self, smsc, async_cb, async_eb): + """ + Sets the SMSC to ``smsc`` + + :param smsc: The SMSC to use + """ + d = self.sconn.set_smsc(smsc) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @signal(dbus_interface=SMS_INTFACE, signature='u') + def SMSReceived(self, index): + log.msg('emitting SMSReceived(%d)' % index) + + +class ContactsExporter(SMSExporter): + """ + I export the org.freedesktop.ModemManager.Modem.Gsm.Contacts interface + """ + @method(CTS_INTFACE, in_signature='ss', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def Add(self, name, number, async_cb, async_eb): + """ + Adds a contact and returns the index + + :param name: The contact name + :param number: The contact number + :rtype: int + """ + d = self.sconn.add_contact(Contact(name, number)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='u', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Delete(self, index, async_cb, async_eb): + """ + Deletes the contact at ``index`` + + :param index: The index of the contact to be deleted + """ + d = self.sconn.delete_contact(index) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='ssu', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def Edit(self, name, number, index, async_cb, async_eb): + """ + Edits the contact at ``index`` + + :param name: The new name of the contact to be edited + :param number: The new number of the contact to be edited + :param index: The index of the contact to be edited + """ + d = self.sconn.add_contact(Contact(name, number, index)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='s', out_signature='a(uss)', + async_callbacks=('async_cb', 'async_eb')) + def Find(self, pattern, async_cb, async_eb): + """ + Returns list of contacts that match ``pattern`` + + :param pattern: The pattern to match contacts against + :rtype: list + """ + d = self.sconn.find_contacts(pattern) + d.addCallback(lambda contacts: + [(c.index, c.name, c.number) for c in contacts]) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='s', out_signature='a(uss)', + async_callbacks=('async_cb', 'async_eb')) + def FindByNumber(self, number, async_cb, async_eb): + """ + Returns list of contacts that match the given ``number`` + + :param number: The number to match contacts against + :rtype: list + """ + d = self.sconn.get_contacts() + d.addCallback(lambda contacts: + [(c.index, c.name, c.number) for c in contacts + if c.number == number]) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='u', out_signature='(uss)', + async_callbacks=('async_cb', 'async_eb')) + def Get(self, index, async_cb, async_eb): + """ + Returns the contact at ``index`` + + :param index: The index of the contact to get + :rtype: tuple + """ + d = self.sconn.get_contact_by_index(index) + d.addCallback(lambda c: (c.index, c.name, c.number)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='', out_signature='u', + async_callbacks=('async_cb', 'async_eb')) + def GetCount(self, async_cb, async_eb): + """Returns the number of contacts in the SIM""" + d = self.sconn.get_contacts() + d.addCallback(lambda contacts: len(contacts)) + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='', out_signature='i', + async_callbacks=('async_cb', 'async_eb')) + def GetPhonebookSize(self, async_cb, async_eb): + """Returns the phonebook size""" + d = self.sconn.get_phonebook_size() + return self.add_callbacks(d, async_cb, async_eb) + + @method(CTS_INTFACE, in_signature='', out_signature='a(uss)', + async_callbacks=('async_cb', 'async_eb')) + def List(self, async_cb, async_eb): + """ + Returns all the contacts in the SIM + + :rtype: list of tuples + """ + d = self.sconn.get_contacts() + d.addCallback(lambda contacts: + [(c.index, c.name, c.number) for c in contacts]) + return self.add_callbacks(d, async_cb, async_eb) + + +class WCDMAExporter(ContactsExporter): + """I export the org.freedesktop.ModemManager.Modem* interface""" + + def __str__(self): + return self.device.__remote_name__ + + __repr__ = __str__ + + +class HSOExporter(WCDMAExporter): + """I export the org.freedesktop.ModemManager.Modem.Gsm.Hso interface""" + + @method(HSO_INTFACE, in_signature='ss', out_signature='', + async_callbacks=('async_cb', 'async_eb')) + def Authenticate(self, user, passwd, async_cb, async_eb): + """ + Authenticate using ``user`` and ``passwd`` + + :param user: The username to be used in authentication + :param passwd: The password to be used in authentication + """ + d = self.sconn.hso_authenticate(user, passwd) + return self.add_callbacks_and_swallow(d, async_cb, async_eb) + diff --git a/wader/common/hardware/__init__.py b/wader/common/hardware/__init__.py new file mode 100644 index 0000000..96526a6 --- /dev/null +++ b/wader/common/hardware/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +The hardware module contains device family-specific code and utils +""" diff --git a/wader/common/hardware/base.py b/wader/common/hardware/base.py new file mode 100644 index 0000000..2f5a160 --- /dev/null +++ b/wader/common/hardware/base.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Base classes for the hardware module""" + +import serial +from twisted.internet.threads import deferToThread +from twisted.python import log + +from wader.common.command import get_cmd_dict_copy +from wader.common.middleware import WCDMAWrapper +from wader.common.plugin import PluginManager +from wader.common.statem.auth import AuthStateMachine +from wader.common.statem.simple import SimpleStateMachine +from wader.common.statem.networkreg import NetworkRegistrationStateMachine +import wader.common.exceptions as ex + +class WCDMACustomizer(object): + """ + I contain all the custom classes and metadata that a WCDMA device needs + + :cvar wrapper_klass: Wrapper for the device + :cvar exporter_klass: DBus Exporter for the device + :cvar async_regexp: regexp to parse asynchronous notifications emited + by the device. + :cvar conn_dict: Dictionary with the AT strings necessary to change + between the different connection modes + :cvar cmd_dict: Dictionary with commands info + :cvar device_capabilities: List with the unsolicited notifications that + this device supports + :cvar auth_klass: Class that will handle the authentication for this device + :cvar netr_klass: Class that will handle the network registration for this + device + """ + from wader.common.exported import WCDMAExporter + wrapper_klass = WCDMAWrapper + exporter_klass = WCDMAExporter + async_regexp = None + band_dict = {} + conn_dict = {} + cmd_dict = get_cmd_dict_copy() + device_capabilities = [] + signal_translations = {} + auth_klass = AuthStateMachine + simp_klass = SimpleStateMachine + netr_klass = NetworkRegistrationStateMachine + + +def _identify_device(port): + """Returns the model of the device present at `port`""" + # as the readlines method blocks, this is executed in a parallel thread + # with deferToThread + ser = serial.Serial(port, timeout=1) + ser.write('ATZ E0 V1 X4 &C1\r\n') + ser.readlines() + + ser.flushOutput() + ser.flushInput() + + ser.write('AT+CGMM\r\n') + # clean up unsolicited notifications and \r\n's + response = [r.replace('\r\n', '') for r in ser.readlines() + if not r.startswith(('^', '_')) and r.replace('\r\n','')] + if response and response[0].startswith('AT+CGMM'): + response.pop(0) + + assert len(response), "Modem didn't reply anything meaningless" + log.msg("at+cgmm response: %s" % response[0]) + ser.close() + + return response[0] + +def identify_device(plugin): + """Returns a :class:`~wader.common.plugin.DevicePlugin` out of `plugin`""" + def identify_device_cb(model): + # plugin to return + _plugin = None + + if plugin.mapping: + if model in plugin.mapping: + _plugin = plugin.mapping[model]() + + # the plugin has no mapping, chances are that we already identified + # it by its vendor & product id + elif plugin.__remote_name__ != model: + # so we basically have a device identified by vendor & product id + # but we know nuthin of this model + try: + _plugin = PluginManager.get_plugin_by_remote_name(model) + except ex.UnknownPluginNameError: + plugin.name = model + + if _plugin is not None: + # we found another plugin during the process + _plugin.patch(plugin) + return _plugin + else: + return plugin + + ports = plugin.ports + port = ports.has_two() and ports.cport or ports.dport + d = deferToThread(_identify_device, port.path) + d.addCallback(identify_device_cb) + return d + diff --git a/wader/common/hardware/huawei.py b/wader/common/hardware/huawei.py new file mode 100644 index 0000000..87726c0 --- /dev/null +++ b/wader/common/hardware/huawei.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all Huawei cards""" + +import re + +from twisted.python import log + +from wader.common.middleware import WCDMAWrapper +from wader.common.command import get_cmd_dict_copy, build_cmd_dict +from wader.common import consts +from wader.common.hardware.base import WCDMACustomizer +from wader.common.netspeed import bps_to_human +from wader.common.plugin import DevicePlugin +from wader.common.sim import SIMBaseClass +from wader.common.utils import rssi_to_percentage +import wader.common.signals as S +import wader.common.aterrors as E + +NETINFO_REGEXP = re.compile('[^a-zA-Z0-9.\-\s]*') +BADOPER_REGEXP = re.compile('FFF*') + +HUAWEI_CONN_DICT = { + consts.MM_NETWORK_MODE_GPRS : (13, 1), + consts.MM_NETWORK_MODE_EDGE : (13, 1), + consts.MM_NETWORK_MODE_2G_ONLY : (13, 1), + + consts.MM_NETWORK_MODE_UMTS : (14, 2), + consts.MM_NETWORK_MODE_HSDPA : (14, 2), + consts.MM_NETWORK_MODE_HSUPA : (14, 2), + consts.MM_NETWORK_MODE_HSPA : (14, 2), + consts.MM_NETWORK_MODE_3G_ONLY : (14, 2), + + consts.MM_NETWORK_MODE_2G_PREFERRED: (2, 1), + + consts.MM_NETWORK_MODE_3G_PREFERRED: (2, 2), +} + +HUAWEI_BAND_DICT = { + consts.MM_NETWORK_BAND_ANY : 0x3FFFFFFF, + + consts.MM_NETWORK_BAND_DCS : 0x00000080, + consts.MM_NETWORK_BAND_EGSM : 0x00000100, + consts.MM_NETWORK_BAND_PCS : 0x00200000, + + consts.MM_NETWORK_BAND_G850 : 0x00080000, + consts.MM_NETWORK_BAND_U2100 : 0x00400000, + consts.MM_NETWORK_BAND_U1900 : 0x00800000, + + consts.MM_NETWORK_BAND_U850 : 0x04000000, + +} + + +def huawei_new_conn_mode(args): + """Translates `args` to Wader's internal representation""" + mode_args_dict = { + '0,0' : S.NO_SIGNAL, + '0,2' : S.NO_SIGNAL, + '3,0' : S.GPRS_SIGNAL, + '3,1' : S.GPRS_SIGNAL, + '3,2' : S.GPRS_SIGNAL, + '3,3' : S.GPRS_SIGNAL, + '5,4' : S.UMTS_SIGNAL, + '5,5' : S.HSDPA_SIGNAL, + '5,6' : S.HSUPA_SIGNAL, + '5,7' : S.HSPA_SIGNAL, + } + return mode_args_dict[args] + +def huawei_new_speed_link(args): + converted_args = map(lambda hexstr: int(hexstr, 16), args.split(',')) + time, tx, rx, tx_flow, rx_flow, tx_rate, rx_rate = converted_args + return bps_to_human(tx * 8, rx * 8) + +HUAWEI_CMD_DICT = get_cmd_dict_copy() +HUAWEI_CMD_DICT['get_syscfg'] = build_cmd_dict(re.compile(r""" + \r\n + \^SYSCFG: + (?P\d+), + (?P\d+), + (?P[0-9A-F]*), + (?P\d), + (?P\d) + \r\n + """, re.VERBOSE)) + +HUAWEI_CMD_DICT['get_radio_status'] = build_cmd_dict( + end=re.compile('\r\n\+CFUN:\s?\d\r\n'), + extract=re.compile('\r\n\+CFUN:\s?(?P\d)\r\n')) + + +class HuaweiWCDMACustomizer(WCDMACustomizer): + """WCDMA Customizer class for Huawei cards""" + async_regexp = re.compile('\r\n(?P\^[A-Z]{3,9}):(?P.*)\r\n') + band_dict = HUAWEI_BAND_DICT + conn_dict = HUAWEI_CONN_DICT + cmd_dict = HUAWEI_CMD_DICT + device_capabilities = [S.SIG_NETWORK_MODE, + S.SIG_RSSI, + S.SIG_SPEED] + + signal_translations = { + '^MODE' : (S.SIG_NETWORK_MODE, huawei_new_conn_mode), + '^RSSI' : (S.SIG_RSSI, lambda rssi: rssi_to_percentage(int(rssi))), + '^DSFLOWRPT' : (S.SIG_SPEED, huawei_new_speed_link), + '^BOOT' : (None, None), + '^SRVST' : (None, None), + '^SIMST' : (None, None), + '^CEND' : (None, None), + } + + +class HuaweiWrapper(WCDMAWrapper): + """Wrapper for all Huawei cards""" + + def _get_syscfg(self): + def parse_syscfg(resp): + ret = {} + mode_a = int(resp[0].group('modea')) + mode_b = int(resp[0].group('modeb')) + band = int(resp[0].group('theband'), 16) + + # keep original values + ret['roam'] = int(resp[0].group('roam')) + ret['srv'] = int(resp[0].group('srv')) + ret['modea'] = mode_a + ret['modeb'] = mode_b + ret['theband'] = band + ret['band'] = 0 # populated later on + + # network mode + if mode_a == 2 and mode_b == 1: + ret['mode'] = consts.MM_NETWORK_MODE_2G_PREFERRED + elif mode_a == 2 and mode_b == 2: + ret['mode'] = consts.MM_NETWORK_MODE_3G_PREFERRED + elif mode_a == 13 and mode_b == 1: + ret['mode'] = consts.MM_NETWORK_MODE_2G_ONLY + elif mode_a == 14 and mode_b == 2: + ret['mode'] = consts.MM_NETWORK_MODE_3G_ONLY + + # band + not_combinable_band = False + if band == 0x3FFFFFFF: + ret['band'] = consts.MM_NETWORK_BAND_ANY + not_combinable_band = True + + if not_combinable_band: + # bands are not combinable by firmware spec + return ret + + for key, value in HUAWEI_BAND_DICT.items(): + if key == consts.MM_NETWORK_BAND_ANY: + # ANY can't be combined + continue + + if value & band: + ret['band'] |= key + + return ret + + d = self.send_at('AT^SYSCFG?', name='get_syscfg', + callback=parse_syscfg) + return d + + def get_band(self): + """Returns the current used band""" + d = self._get_syscfg() + d.addCallback(lambda ret: ret['band']) + d.addErrback(log.err) + return d + + def get_network_info(self): + def process_netinfo_cb(info): + operator, tech = info + m = BADOPER_REGEXP.match(operator) + # sometimes the operator will come as a FFFFFFF+ + if m: + return "Unknown Network", tech + + # clean extra '@', 'x1a', etc + return NETINFO_REGEXP.sub('', operator), tech + + d = super(HuaweiWrapper, self).get_network_info() + d.addCallback(process_netinfo_cb) + return d + + def get_network_mode(self): + """Returns the current used network mode""" + d = self._get_syscfg() + d.addCallback(lambda ret: ret['mode']) + d.addErrback(log.err) + return d + + def set_band(self, band): + """Sets the band to ``band``""" + def get_syscfg_cb(info): + mode_a, mode_b = info['modea'], info['modeb'] + roam, srv = info['roam'], info['srv'] + + _band = 0 + if band == consts.MM_NETWORK_BAND_ANY: + # ANY cannot be combined by design + _band = 0x3FFFFFFF + else: + # the rest can be combined + for key, value in HUAWEI_BAND_DICT.items(): + if key == consts.MM_NETWORK_BAND_ANY: + continue + + if key & band: + _band |= value + + if _band == 0: + # if we could not satisfy the request, leave the band + # in its original state + _band = info['theband'] + + at_str = 'AT^SYSCFG=%d,%d,%X,%d,%d' + + return self.send_at(at_str % (mode_a, mode_b, _band, roam, srv)) + + d = self._get_syscfg() + d.addCallback(get_syscfg_cb) + return d + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + def get_syscfg_cb(info): + _mode, acqorder = info['modea'], info['modeb'] + band, roam, srv = info['theband'], info['roam'], info['srv'] + + if mode in HUAWEI_CONN_DICT: + _mode, acqorder = HUAWEI_CONN_DICT[mode] + + at_str = 'AT^SYSCFG=%d,%d,%X,%d,%d' + return self.send_at(at_str % (_mode, acqorder, band, roam, srv)) + + d = self._get_syscfg() + d.addCallback(get_syscfg_cb) + d.addErrback(log.err) + return d + + def set_smsc(self, smsc): + """ + Sets the SIM's smsc to `smsc` + + We wrap the operation with set_charset('IRA') and set_charset('UCS2') + """ + d = self.set_charset('IRA') + d.addCallback(lambda _: super(HuaweiWrapper, self).set_smsc(smsc)) + d.addCallback(lambda _: self.set_charset('UCS2')) + return d + + +class HuaweiSIMClass(SIMBaseClass): + """Huawei SIM Class""" + def __init__(self, sconn): + super(HuaweiSIMClass, self).__init__(sconn) + + def initialize(self, set_encoding=True): + def at_curc_eb(failure): + failure.trap(E.GenericError) + + d = super(HuaweiSIMClass, self).initialize(set_encoding=set_encoding) + def init_cb(size): + # enable unsolicited control commands + d = self.sconn.send_at('AT^CURC=1') + d.addErrback(at_curc_eb) + + self.sconn.send_at('AT+COPS=3,0') + return size + + d.addCallback(init_cb) + return d + + +class HuaweiCustomizer(HuaweiWCDMACustomizer): + """Customizer for all Huawei cards""" + wrapper_klass = HuaweiWrapper + + +class HuaweiWCDMADevicePlugin(DevicePlugin): + """DevicePlugin for Huawei""" + sim_klass = HuaweiSIMClass + custom = HuaweiCustomizer() + + diff --git a/wader/common/hardware/novatel.py b/wader/common/hardware/novatel.py new file mode 100644 index 0000000..df7a80c --- /dev/null +++ b/wader/common/hardware/novatel.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all Novatel's cards""" + +import re + +from wader.common import consts +from wader.common.command import get_cmd_dict_copy, build_cmd_dict +from wader.common.hardware.base import WCDMACustomizer +from wader.common.middleware import WCDMAWrapper +from wader.common.plugin import DevicePlugin +from wader.common.utils import revert_dict + +NOVATEL_MODE_DICT = { + consts.MM_NETWORK_MODE_ANY : '0,2', + consts.MM_NETWORK_MODE_2G_ONLY : '1,1', + consts.MM_NETWORK_MODE_3G_ONLY : '2,1', + consts.MM_NETWORK_MODE_2G_PREFERRED : '1,2', + consts.MM_NETWORK_MODE_3G_PREFERRED : '2,2', +} + +NOVATEL_BAND_DICT = {} +NOVATEL_CMD_DICT = get_cmd_dict_copy() + +NOVATEL_CMD_DICT['get_network_mode'] = build_cmd_dict( + re.compile("\r\n\$NWRAT:\s?(?P\d,\d)\r\n")) + +class NovatelWrapper(WCDMAWrapper): + """Wrapper for all Novatel cards""" + + def get_network_mode(self): + """Returns the current network mode""" + def get_network_mode_cb(resp): + mode = resp[0].group('mode') + return revert_dict(NOVATEL_MODE_DICT)[mode] + + return self.send_at("AT$NWRAT?", name='get_network_mode', + callback=get_network_mode_cb) + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + if mode not in NOVATEL_MODE_DICT: + raise KeyError("Unknown network mode %d" % mode) + + return self.send_at("AT$NWRAT=%s" % NOVATEL_MODE_DICT[mode]) + + +class NovatelWCDMACustomizer(WCDMACustomizer): + """WCDMA customizer for Novatel cards""" + async_regexp = None + conn_dict = NOVATEL_MODE_DICT + band_dict = NOVATEL_BAND_DICT + cmd_dict = NOVATEL_CMD_DICT + wrapper_klass = NovatelWrapper + + +class NovatelWCDMADevicePlugin(DevicePlugin): + """WCDMA device plugin for Novatel cards""" + custom = NovatelWCDMACustomizer() + diff --git a/wader/common/hardware/option.py b/wader/common/hardware/option.py new file mode 100644 index 0000000..0205c36 --- /dev/null +++ b/wader/common/hardware/option.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all Option's datacards/devices""" + +import re + +from twisted.internet import defer, reactor + +from wader.common.command import get_cmd_dict_copy, build_cmd_dict +from wader.common import consts +from wader.common.middleware import WCDMAWrapper +from wader.common.exported import HSOExporter +from wader.common.hardware.base import WCDMACustomizer +from wader.common.aterrors import GenericError +from wader.common.sim import SIMBaseClass +from wader.common.plugin import DevicePlugin +from wader.common.utils import rssi_to_percentage, revert_dict +import wader.common.signals as S + +NUM_RETRIES = 30 +RETRY_TIMEOUT = 4 + +OPTION_BAND_MAP_DICT = { + 'ANY' : consts.MM_NETWORK_BAND_ANY, + 'EGSM' : consts.MM_NETWORK_BAND_EGSM, + 'DCS' : consts.MM_NETWORK_BAND_DCS, + 'PCS' : consts.MM_NETWORK_BAND_PCS, + 'G850' : consts.MM_NETWORK_BAND_G850, + 'U2100' : consts.MM_NETWORK_BAND_U2100, + 'U1900' : consts.MM_NETWORK_BAND_U1900, + 'U1700' : consts.MM_NETWORK_BAND_U1700, + '17IV' : consts.MM_NETWORK_BAND_17IV, + 'U850' : consts.MM_NETWORK_BAND_U850, + 'U800' : consts.MM_NETWORK_BAND_U850, + 'U900' : consts.MM_NETWORK_BAND_U900, + 'U17IX' : consts.MM_NETWORK_BAND_U17IX, +} + +OPTION_CONN_DICT = { + consts.MM_NETWORK_MODE_ANY : 0, + consts.MM_NETWORK_MODE_GPRS : 0, + consts.MM_NETWORK_MODE_EDGE : 0, + consts.MM_NETWORK_MODE_2G_ONLY : 0, + + consts.MM_NETWORK_MODE_UMTS : 1, + consts.MM_NETWORK_MODE_HSDPA : 1, + consts.MM_NETWORK_MODE_HSUPA : 1, + consts.MM_NETWORK_MODE_HSPA : 1, + consts.MM_NETWORK_MODE_3G_ONLY : 1, + + consts.MM_NETWORK_MODE_2G_PREFERRED : 2, + + consts.MM_NETWORK_MODE_3G_PREFERRED : 3, +} + +# The option band dictionary does not need to be specified as we +# modelled the band dict after it + +# Option devices like to append its serial number after the IMEI, ignore it +OPTION_CMD_DICT = get_cmd_dict_copy() +OPTION_CMD_DICT['get_imei'] = build_cmd_dict(re.compile( + "\r\n(?P\d+),\S+\r\n", re.VERBOSE)) + +OPTION_CMD_DICT['get_sim_status'] = build_cmd_dict(re.compile(r""" + _OBLS:\s(?P\d), + (?P\d), + (?P\d) + """, re.VERBOSE)) + +OPTION_CMD_DICT['get_band'] = build_cmd_dict(re.compile(r""" + \r\n(?P.*):\s+(?P\d) + """, re.VERBOSE)) + +OPTION_CMD_DICT['get_network_mode'] = build_cmd_dict(re.compile(r""" + _OPSYS:\s + (?P\d), + (?P\d) + """, re.VERBOSE)) + +class OptionSIMClass(SIMBaseClass): + """ + Option SIM Class + + I perform an initial setup in the device and will not + return until the SIM is *really* ready + """ + def __init__(self, sconn): + super(OptionSIMClass, self).__init__(sconn) + self.num_retries = 0 + + def initialize(self, set_encoding=True): + deferred = defer.Deferred() + + def init_callback(size): + # setup asynchronous notifications + self.sconn.send_at('AT_OSSYS=1') # cell change notification + self.sconn.send_at('AT_OSQI=1') # signal quality notification + deferred.callback(size) + + def sim_ready_cb(ignored): + d2 = super(OptionSIMClass, self).initialize(set_encoding) + d2.addCallback(init_callback) + + def sim_ready_eb(failure): + deferred.errback(failure) + + d = self.is_sim_ready() + d.addCallback(sim_ready_cb) + d.addErrback(sim_ready_eb) + + return deferred + + def is_sim_ready(self): + deferred = defer.Deferred() + + def process_sim_state(auxdef): + def parse_response(resp): + status = tuple(map(int, resp[0].groups())) + if status == (1, 1, 1): + auxdef.callback(True) + else: + self.num_retries += 1 + if self.num_retries < NUM_RETRIES: + reactor.callLater(RETRY_TIMEOUT, + process_sim_state, auxdef) + else: + msg = "Max number of attempts reached %d" + auxdef.errback(GenericError(msg % self.num_retries)) + + return + + self.sconn.send_at('AT_OBLS', name='get_sim_status', + callback=parse_response) + + return auxdef + + return process_sim_state(deferred) + + +def new_conn_mode_cb(args): + """ + Translates Option's unsolicited notifications to Wader's representation + """ + ossysi_args_dict = { + '0' : S.GPRS_SIGNAL, + '2' : S.UMTS_SIGNAL, + '3' : S.NO_SIGNAL, + } + return ossysi_args_dict[args] + + +class OptionWrapper(WCDMAWrapper): + """Wrapper for all Option cards""" + + def _get_band_dict(self): + """Returns a dict with the available bands and its status""" + def callback(resp): + bands = {} + + for r in resp: + name, active = r.group('name'), int(r.group('active')) + bands[name] = active + + return bands + + d = self.send_at('AT_OPBM?', name='get_band', callback=callback) + return d + + def get_band(self): + """Returns the current used band""" + def get_band_dict_cb(bands): + if 'ANY' in bands and bands['ANY'] == 1: + # can't be combined by design + return consts.MM_NETWORK_BAND_ANY + + ret = 0 + for name, active in bands.items(): + if not active: + continue + + if name in OPTION_BAND_MAP_DICT: + ret |= OPTION_BAND_MAP_DICT[name] + + return ret + + d = self._get_band_dict() + d.addCallback(get_band_dict_cb) + return d + + def get_network_mode(self): + """Returns the current network mode""" + ret_codes = { + 0 : consts.MM_NETWORK_MODE_2G_ONLY, + 1 : consts.MM_NETWORK_MODE_3G_ONLY, + 2 : consts.MM_NETWORK_MODE_2G_PREFERRED, + 3 : consts.MM_NETWORK_MODE_3G_PREFERRED, + 5 : consts.MM_NETWORK_MODE_ANY, + } + def callback(resp): + mode = int(resp[0].group('mode')) + if mode in ret_codes: + return ret_codes[mode] + + raise KeyError("Unknown network mode %d" % mode) + + d = self.send_at('AT_OPSYS?', name='get_network_mode', + callback=callback) + return d + + def set_band(self, band): + """Sets the band to ``band``""" + + def get_band_dict_cb(bands): + responses = [] + + at_str = 'AT_OPBM="%s",%d' + + if band == consts.MM_NETWORK_BAND_ANY: + if 'ANY' in bands and bands['ANY'] == 1: + # if ANY is already enabled, do nothing + return defer.succeed(True) + + # enabling ANY will suffice + responses.append(self.send_at(at_str % ('ANY', 1))) + else: + # ANY is not sought, if ANY is enabled we should remove it first + # bitwise bands + if 'ANY' in bands and bands['ANY'] == 1: + responses.append(self.send_at(at_str % ('ANY', 0))) + + for key, value in OPTION_BAND_MAP_DICT.items(): + if value == consts.MM_NETWORK_BAND_ANY: + # do not attempt to combine it + continue + + if value & band: + # enable required band + responses.append(self.send_at(at_str % (key, 1))) + else: + # disable required band + responses.append(self.send_at(at_str % (key, 0))) + + if responses: + dlist = defer.DeferredList(responses, consumeErrors=1) + dlist.addCallback(lambda l: [x[1] for x in l]) + return dlist + + raise KeyError("OptionWrapper: Unknown band mode %d" % band) + + # due to Option's band API, we'll start by obtaining the current bands + d = self._get_band_dict() + d.addCallback(get_band_dict_cb) + return d + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + if mode not in OPTION_CONN_DICT: + raise KeyError("Unknown mode %d for set_network_mode" % mode) + + value = OPTION_CONN_DICT[mode] + return self.send_at("AT_OPSYS=%d,2" % value) + + +class OptionWCDMACustomizer(WCDMACustomizer): + """Customizer for Option's cards""" + async_regexp = re.compile(r""" + \r\n + (?P_O[A-Z]{3,}):\s(?P.*) + \r\n""", re.VERBOSE) + # the dict is reverted as we are interested in the range of bands + # that the device supports (get_bands) + band_dict = revert_dict(OPTION_BAND_MAP_DICT) + conn_dict = OPTION_CONN_DICT + cmd_dict = OPTION_CMD_DICT + device_capabilities = [S.SIG_NETWORK_MODE, S.SIG_RSSI] + signal_translations = { + '_OSSYSI' : (S.SIG_NETWORK_MODE, new_conn_mode_cb), + '_OSIGQ' : (S.SIG_RSSI, lambda args: + (rssi_to_percentage(int(args.split(',')[0])))) + } + wrapper_klass = OptionWrapper + + +class OptionHSOWCDMACustomizer(OptionWCDMACustomizer): + """Customizer for HSO WCDMA devices""" + exporter_klass = HSOExporter + + +class OptionWCDMADevicePlugin(DevicePlugin): + """DevicePlugin for Option""" + sim_klass = OptionSIMClass + custom = OptionWCDMACustomizer() + + def __init__(self): + super(OptionWCDMADevicePlugin, self).__init__() + + +class OptionHSOWCDMADevicePlugin(OptionWCDMADevicePlugin): + """DevicePlugin for Option HSO devices""" + custom = OptionHSOWCDMACustomizer() + + def __init__(self): + super(OptionHSOWCDMADevicePlugin, self).__init__() + + diff --git a/wader/common/hardware/sierra.py b/wader/common/hardware/sierra.py new file mode 100644 index 0000000..a2b6ca2 --- /dev/null +++ b/wader/common/hardware/sierra.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all SierraWireless cards""" + +import re + +from wader.common import consts +from wader.common.command import get_cmd_dict_copy, build_cmd_dict +from wader.common.hardware.base import WCDMACustomizer +from wader.common.middleware import WCDMAWrapper +from wader.common.plugin import DevicePlugin +from wader.common.utils import revert_dict + +SIERRA_MODE_DICT = { + consts.MM_NETWORK_MODE_ANY : '00', + consts.MM_NETWORK_MODE_3G_ONLY : '01', + consts.MM_NETWORK_MODE_2G_ONLY : '02', + consts.MM_NETWORK_MODE_3G_PREFERRED : '03', + consts.MM_NETWORK_MODE_2G_PREFERRED : '04', +} + +SIERRA_BAND_DICT = { + consts.MM_NETWORK_BAND_EGSM : '03', # EGSM (900MHz) + consts.MM_NETWORK_BAND_DCS : '03', # DCS (1800MHz) + consts.MM_NETWORK_BAND_PCS : '04', # PCS (1900MHz) + consts.MM_NETWORK_BAND_G850 : '04', # GSM (850 MHz) + consts.MM_NETWORK_BAND_U2100 : '02', # WCDMA 2100Mhz (Class I) + consts.MM_NETWORK_BAND_U800 : '02', # WCDMA 3GPP UMTS800 (Class VI) + consts.MM_NETWORK_BAND_ANY : '00', # any band +} + +SIERRA_CMD_DICT = get_cmd_dict_copy() + +SIERRA_CMD_DICT['get_netreg_status'] = build_cmd_dict(re.compile( + r""" + \r\n + \+CREG:\s + (?P\d),(?P\d+)(,[0-9a-fA-F]*,[0-9a-fA-F]*)? + \r\n""", re.VERBOSE)) + +SIERRA_CMD_DICT['get_band'] = build_cmd_dict(re.compile( + "\r\n\!BAND:\s?(?P\d+)")) + +SIERRA_CMD_DICT['get_network_mode'] = build_cmd_dict(re.compile( + "\r\n\!SELRAT:\s?(?P\d+)")) + + +class SierraWrapper(WCDMAWrapper): + """Wrapper for all Sierra cards""" + + def get_band(self): + """Returns the current used band""" + def get_band_cb(resp): + band = resp[0].group('band') + return revert_dict(SIERRA_BAND_DICT)[band] + + return self.send_at("AT!BAND?", name='get_band', + callback=get_band_cb) + + def get_network_mode(self): + """Returns the current used network mode""" + def get_network_mode_cb(resp): + mode = resp[0].group('mode') + return revert_dict(SIERRA_MODE_DICT)[mode] + + return self.send_at("AT!SELRAT?", name='get_network_mode', + callback=get_network_mode_cb) + + def set_band(self, band): + """Sets the band to ``band``""" + if band not in SIERRA_BAND_DICT: + raise KeyError("Unknown band %d" % band) + + return self.send_at("AT!BAND=%s" % SIERRA_BAND_DICT[band]) + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + if mode not in SIERRA_MODE_DICT: + raise KeyError("Unknown mode %d" % mode) + + return self.send_at("AT!SELRAT=%s" % SIERRA_MODE_DICT[mode]) + + +class SierraWirelessWCDMACustomizer(WCDMACustomizer): + """WCDMA customizer for sierra wireless cards""" + async_regexp = None + band_dict = SIERRA_BAND_DICT + conn_dict = SIERRA_MODE_DICT + cmd_dict = SIERRA_CMD_DICT + wrapper_klass = SierraWrapper + + +class SierraWCDMADevicePlugin(DevicePlugin): + """WCDMA device plugin for sierra wireless cards""" + custom = SierraWirelessWCDMACustomizer() + diff --git a/wader/common/hardware/sonyericsson.py b/wader/common/hardware/sonyericsson.py new file mode 100644 index 0000000..448125b --- /dev/null +++ b/wader/common/hardware/sonyericsson.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all SonyEricsson's cards""" + +from wader.common.hardware.base import WCDMACustomizer + +SONYERICSSON_CONN_DICT = {} +SONYERICSSON_BAND_DICT = {} + +class SonyEricssonCustomizer(WCDMACustomizer): + """WCDMA customizer for sonny ericsson devices""" + async_regexp = None + conn_dict = SONYERICSSON_CONN_DICT + band_dict = SONYERICSSON_BAND_DICT + diff --git a/wader/common/hardware/zte.py b/wader/common/hardware/zte.py new file mode 100644 index 0000000..843524d --- /dev/null +++ b/wader/common/hardware/zte.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2007 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Common stuff for all zte's cards""" + +import re + +from wader.common import consts +from wader.common.command import get_cmd_dict_copy, build_cmd_dict +from wader.common.hardware.base import WCDMACustomizer +from wader.common.middleware import WCDMAWrapper +from wader.common.plugin import DevicePlugin +from wader.common.utils import revert_dict +import wader.common.signals as S + +ZTE_MODE_DICT = { + consts.MM_NETWORK_MODE_ANY : (0, 0, 0), + consts.MM_NETWORK_MODE_2G_ONLY : (1, 0, 0), + consts.MM_NETWORK_MODE_3G_ONLY : (2, 0, 0), + consts.MM_NETWORK_MODE_2G_PREFERRED : (0, 0, 1), + consts.MM_NETWORK_MODE_3G_PREFERRED : (1, 0, 2), +} + +ZTE_BAND_DICT = { + consts.MM_NETWORK_BAND_PCS : 4, # PCS (1900MHz) + consts.MM_NETWORK_BAND_G850 : 3, # GSM (850 MHz) + consts.MM_NETWORK_BAND_U2100 : 2, # WCDMA 2100Mhz (Class I) + consts.MM_NETWORK_BAND_U850 : 1, # WCDMA 3GPP UMTS850 (Class V) + consts.MM_NETWORK_BAND_ANY : 0, # any band +} + +ZTE_CMD_DICT = get_cmd_dict_copy() + +ZTE_CMD_DICT['get_band'] = build_cmd_dict(re.compile(r""" + \r\n + \+ZBANDI:\s? + (?P\d) + \r\n + """, re.VERBOSE)) + +ZTE_CMD_DICT['get_netreg_status'] = build_cmd_dict(re.compile(r""" + \r\n + \+CREG:\s + (?P\d), + (?P\d+)(,[0-9a-fA-F]*,[0-9a-fA-F]*)? + \r\n""", re.VERBOSE)) + +ZTE_CMD_DICT['get_network_mode'] = build_cmd_dict(re.compile(r""" + \r\n + \+ZPAS:\s + "(?P.*)", + "(?P.*)", + \r\n""", re.VERBOSE)) + +def zte_new_conn_mode(args): + what = args.replace('"', '') + if what in "UMTS": + return S.UMTS_SIGNAL + elif what in ["GPRS", "GSM"]: + return S.GPRS_SIGNAL + elif what in "HSDPA": + return S.HSDPA_SIGNAL + elif what in "HSUPA": + return S.HSDPA_SIGNAL + elif what in "EDGE": + return S.EDGE_SIGNAL + elif what in ["No service", "Limited Service"]: + return S.NO_SIGNAL + + +class ZTEWrapper(WCDMAWrapper): + """Wrapper for all ZTE cards""" + + def get_band(self): + """Returns the current used band""" + def get_band_cb(resp): + band = int(resp[0].group('band')) + return revert_dict(ZTE_BAND_DICT)[band] + + return self.send_at("at+zbandi?", name='get_band', + callback=get_band_cb) + + def set_band(self, band): + """Sets the band to ``band``""" + if band not in ZTE_BAND_DICT: + raise KeyError("Band %d not found" % band) + + return self.send_at("at+zbandi=%d" % ZTE_BAND_DICT[band]) + + def get_network_mode(self): + """Returns the current used network mode""" + def get_network_mode_cb(resp): + mode = resp[0].group('mode') + + if mode in "UMTS": + return consts.MM_NETWORK_MODE_UMTS + elif mode in ["GPRS", "GSM"]: + return consts.MM_NETWORK_MODE_GPRS + elif mode in ["EDGE"]: + return consts.MM_NETWORK_MODE_EDGE + elif mode in ["HSDPA"]: + return consts.MM_NETWORK_MODE_HSDPA + elif mode in ["HSUPA"]: + return consts.MM_NETWORK_MODE_HSUPA + elif mode in ["HSPA"]: + return consts.MM_NETWORK_MODE_HSPA + + raise ValueError("Can not translate mode %s" % mode) + + return self.send_at("AT+ZPAS?", name='get_network_mode', + callback=get_network_mode_cb) + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + if mode not in ZTE_MODE_DICT: + raise KeyError("Mode %s not found" % mode) + + return self.send_at("AT^ZSNT=%d,%d,%d" % ZTE_MODE_DICT[mode]) + + +class ZTEWCDMACustomizer(WCDMACustomizer): + """WCDMA customizer for ZTE devices""" + async_regexp = re.compile(""" + \r\n + (?P\+Z[A-Z]{3,}):\s*(?P.*) + \r\n""", re.VERBOSE) + band_dict = ZTE_BAND_DICT + conn_dict = ZTE_MODE_DICT + cmd_dict = ZTE_CMD_DICT + device_capabilities = [S.SIG_NETWORK_MODE] + signal_translations = { + '+ZDONR' : (None, None), + '+ZPASR' : (S.SIG_NETWORK_MODE, zte_new_conn_mode), + '+ZUSIMR' : (None, None), + } + wrapper_klass = ZTEWrapper + + +class ZTEWCDMADevicePlugin(DevicePlugin): + """WCDMA device plugin for ZTE devices""" + custom = ZTEWCDMACustomizer() + diff --git a/wader/common/interfaces.py b/wader/common/interfaces.py new file mode 100644 index 0000000..fa1e11b --- /dev/null +++ b/wader/common/interfaces.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Wader interfaces""" + +from zope.interface import Interface, Attribute + +class IContact(Interface): + """Interface that all contact backends must implement""" + name = Attribute("""Contact's name""") + number = Attribute("""Contact's number""") + index = Attribute("""Contact's index""") + + def to_csv(): + """Returns a list with the name and number formatted for csv""" + + +class IMessage(Interface): + """Interface that all message backends must implement""" + number = Attribute("""SMS sender""") + text = Attribute("""SMS text""") + index = Attribute("""Contact's index""") + + +class ICollaborator(Interface): + """ + ICollaborator aids AuthStateMachine providing necessary PIN/PUK/Whatever + + AuthStateMachine needs an object that provides ICollaborator in order to + work. ICollaborator abstracts the mechanism through wich the PIN/PUK is + obtained. + """ + + def get_pin(): + """ + Returns the PIN + + :rtype: `Deferred` + """ + + def get_puk(): + """ + Returns a (puk, sim) tuple + + :rtype: `Deferred` + """ + + def get_puk2(): + """ + Returns a (puk2, sim) tuple + + :rtype: `Deferred` + """ + + +class IDialer(Interface): + + def configure(config, device): + """ + Configures the dialer with `config` for `device` + """ + + def connect(): + """ + Connects to Internet + + :rtype: `Deferred` + """ + + def disconnect(): + """ + Disconnects from Internet + + :rtype: `Deferred` + """ + + def stop(): + """ + Stops the connection attempt + + :rtype: `Deferred` + """ + + +class IWaderPlugin(Interface): + """Base interface for all Wader plugins""" + name = Attribute("""Plugin's name""") + version = Attribute("""Plugin's version""") + author = Attribute("""Plugin's author""") + + def initialize(): + """Initializes the plugin""" + + def close(): + """Closes the plugin""" + + +class IDevicePlugin(IWaderPlugin): + """Interface that all device plugins should implement""" + baudrate = Attribute("""At which speed should we talk with this guy""") + custom = Attribute("""Container with all the device's customizations""") + sim = Attribute("""SIM object""") + sconn = Attribute("""Reference to the serial connection instance""") + __properties__ = Attribute(""" + pairs of properties that must be satisfied by DBus backend""") + + +class IRemoteDevicePlugin(IDevicePlugin): + """Interface that all remote device plugins should implent""" + + __remote_name__ = Attribute("""Response of an AT+CGMM command""") + + +class IOSPlugin(IWaderPlugin): + distrib_id = Attribute("""Name of the OS/Distro""") + distrib_version = Attribute("""Version of the OS/Distro""") + + def add_default_route(iface): + """Sets a default route for ``iface``""" + + def delete_default_route(iface): + """Deletes default route for ``iface``""" + + def add_dns_info(dnsinfo, iface=None): + """ + Adds ``dnsinfo`` to ``iface`` + + type dnsinfo: tuple + """ + + def delete_dns_info(dnsinfo, iface=None): + """Deletes ``dnsinfo`` from ``iface``""" + + def configure_iface(iface, ip='', action='up'): + """ + Configures ``iface`` with ``ip`` and ``action`` + + ``action`` can be either 'up' or 'down'. If you bring down + an iface, ip will be ignored. + """ + + def get_iface_stats(iface): + """Returns ``iface`` network statistics""" + + def get_timezone(): + """ + Returns the timezone of the OS + + :rtype: str + """ + + def get_tzinfo(): + """Returns a :class:`pytz.timezone` out the timezone""" + + def is_valid(): + """Returns True if we are on the given OS/Distro""" + + +class IHardwareManager(Interface): + + def get_devices(): + """ + Returns a list with all the devices present in the system + + :rtype: `Deferred` + """ + + def register_controller(controller): + """ + Registers ``controller`` as the driver class of this HW manager + + This reference will be used to emit Device{Add,Remov}ed signals + upon hotplugging events. + """ + diff --git a/wader/common/keyring.py b/wader/common/keyring.py new file mode 100644 index 0000000..63e68d3 --- /dev/null +++ b/wader/common/keyring.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Keyring module for Wader""" + +import os +try: + import cPickle as pickle +except ImportError: + import pickle + +import gobject +import dbus +from dbus.service import Object, BusName, signal + +from wader.common.consts import (APP_SLUG_NAME, WADER_KEYRING_SERVICE, + WADER_KEYRING_OBJPATH, WADER_KEYRING_INTFACE, + NM_PASSWD) +from wader.common._gconf import GConfHelper +from wader.contrib.aes import (AESModeOfOperation, append_PKCS7_padding, + CBC, SIZE_128) + +# this line is required, otherwise gnomekeyring will complain about +# the application name not being set +gobject.set_application_name(APP_SLUG_NAME) + +KEYRING_AVAILABLE = True +try: + import gnomekeyring +except ImportError: + KEYRING_AVAILABLE = False +else: + try: + gnomekeyring.get_default_keyring_sync() + except gnomekeyring.NoKeyringDaemonError: + KEYRING_AVAILABLE = False + + +class KeyringNoMatchError(Exception): + """Exception raised when there is no match for a keyring request""" + +class KeyringInvalidPassword(Exception): + """Exception raised when the supplied password is invalid""" + +class KeyringIsClosed(Exception): + """ + Exception raised when an operation has been attempted on a closed keyring + """ + +_keyring_manager = None +def get_keyring_manager(base_gpath): + """ + Returns a reference to the :class:`KeyringManager` singleton + + It will use the appropriate keyring backend depending on the system + + :param base_gpath: GConf base path that will be used to store + keyring data + """ + global _keyring_manager + # if is already instantiated + if _keyring_manager is not None: + return _keyring_manager + + if KEYRING_AVAILABLE: + _keyring_manager = KeyringManager(GnomeKeyring()) + return _keyring_manager + + _keyring_manager = KeyringManager(AESKeyring(base_gpath)) + return _keyring_manager + + +class KeyringManager(Object): + """ + I am the keyring manager + + I provide a uniform API over different keyrings + """ + def __init__(self, keyring): + name = BusName(WADER_KEYRING_SERVICE, bus=dbus.SystemBus()) + super(KeyringManager, self).__init__(bus_name=name, + object_path=WADER_KEYRING_OBJPATH) + self.keyring = keyring + + self.open_callbacks = set() + + def _get_keyring_new(self): + return self.keyring.is_new + + def _get_keyring_open(self): + return self.keyring.is_open + + is_open = property(_get_keyring_open) + is_new = property(_get_keyring_new) + + def register_open_callback(self, callback): + """Registers ``callback`` to be executed upon keyring unlock""" + self.open_callbacks.add(callback) + + def delete_secret(self, uuid): + """ + Deletes the secret identified by ``uuid`` + + :raise KeyringIsClosed: When the underlying keyring is closed + """ + if self.keyring.is_open: + return self.keyring.delete(uuid) + + raise KeyringIsClosed() + + def update_secret(self, uuid, conn_id, secrets, update=True): + """ + Updates secret ``secrets`` in profile ``uuid`` + + :param uuid: The uuid of the profile to be updated + :param conn_id: The id (name) of the profile to be updated + :param secrets: The secrets + :params update: Should existing secrets be updated? + + :raise KeyringIsClosed: When the underlying keyring is closed + """ + if self.keyring.is_open: + ret = self.keyring.update(uuid, conn_id, secrets, update) + self.keyring.write() + return ret + + raise KeyringIsClosed() + + def get_secret(self, uuid): + """ + Returns the secrets associated with ``uuid`` + + :param uuid: The UUID of the connection to use + :raise KeyringIsClosed: When the underlying keyring is closed + """ + if self.keyring.is_open: + return self.keyring.get(uuid) + + raise KeyringIsClosed() + + def close(self): + """ + Cleans up the underlying backend and deletes the cached secrets + + :raise KeyringIsClosed: When the underlying keyring is closed + """ + if self.keyring.is_open: + return self.keyring.close() + + raise KeyringIsClosed() + + def write(self): + """ + Writes the changes to the underlying keyring manager + + :raise KeyringIsClosed: When the underlying keyring is closed + """ + if self.keyring.is_open: + return self.keyring.write() + + raise KeyringIsClosed() + + def open(self, password): + """ + Opens the keyring using ``password`` + + If successful, it will execute all the callbacks registered with + :meth:`register_open_callback`. + + :raise KeyringIsClosed: When the underlying keyring is closed + """ + self.keyring.open(password) + + for callback in self.open_callbacks: + callback() + + @signal(WADER_KEYRING_INTFACE, signature="o") + def KeyNeeded(self, opath): + pass + + +class GnomeKeyring(object): + """I just wrap gnome-keyring""" + def __init__(self): + super(GnomeKeyring, self).__init__() + self.is_new = False + self.name = gnomekeyring.get_default_keyring_sync() + + if self.name is None or self.name == '': + self.is_new = True + self.name = 'login' + + try: + gnomekeyring.create_sync(self.name, None) + except gnomekeyring.AlreadyExistsError: + pass + + def _get_is_open(self): + info = gnomekeyring.get_info_sync(self.name) + return not info.get_is_locked() + + is_open = property(_get_is_open) + + def open(self, password): + """See :meth:`KeyringManager.open`""" + if not self.is_open: + try: + gnomekeyring.unlock_sync(self.name, password) + except gnomekeyring.DeniedError: + raise KeyringInvalidPassword() + + def close(self): + """See :meth:`KeyringManager.close`""" + if self.is_open: + gnomekeyring.lock_sync(self.name) + else: + raise KeyringIsClosed() + + def get(self, uuid): + """See :meth:`KeyringManager.get_secret`""" + attrs = {'connection-uuid' : str(uuid)} + try: + secrets = gnomekeyring.find_items_sync( + gnomekeyring.ITEM_GENERIC_SECRET, attrs) + return {'gsm' : {NM_PASSWD: secrets[0].secret}} + except gnomekeyring.NoMatchError: + msg = "No secrets for connection '%s'" + raise KeyringNoMatchError(msg % str(uuid)) + + def update(self, uuid, conn_id, secrets, update=True): + """See :meth:`KeyringManager.update_secret`""" + attrs = {'connection-uuid' : str(uuid), 'setting-name' : 'gsm', + 'setting-key' : 'password'} + + password = secrets['gsm'][NM_PASSWD] + + text = 'Network secret for %s/%s/%s' % (conn_id, 'gsm', 'password') + return gnomekeyring.item_create_sync(self.name, + gnomekeyring.ITEM_GENERIC_SECRET, + text, attrs, password, update) + + def delete(self, uuid): + """See :meth:`KeyringManager.delete_secret`""" + attrs = {'connection-uuid' : str(uuid)} + secrets = gnomekeyring.find_items_sync( + gnomekeyring.ITEM_GENERIC_SECRET, attrs) + # we find the secret, and we delete it + return gnomekeyring.item_delete_sync(self.name, secrets[0].item_id) + + def write(self): + """See :meth:`KeyringManager.write`""" + # NOOP + pass + + +class AESKeyring(GConfHelper): + """ + GConf powered keyring + + I will store the secretss encrypted with TripleDES + """ + + def __init__(self, base_gpath): + super(AESKeyring, self).__init__() + + self.path = os.path.join(base_gpath, 'keyring') + self.is_open = False + self.is_new = True + self._aes = None + self._iv = [101, 32, 138, 239, 76, 213, 47, 118, 255, 222, 123, + 176, 106, 134, 98, 92] + self._key = None + + self._data = None + self._load_data() + + def _load_data(self): + if self.client.dir_exists(self.path): + self.is_new = False + + def open(self, password): + """See :meth:`KeyringManager.open`""" + self._aes = AESModeOfOperation() + self._key = map(ord, append_PKCS7_padding(password)) + + if not self.is_new: + value = self.client.get(os.path.join(self.path, 'data')) + enc_data = self.get_value(value) + orig_len = self.client.get_int(os.path.join(self.path, 'orig_len')) + + dict_data = self._aes.decrypt(enc_data, orig_len, CBC, self._key, + SIZE_128, self._iv) + if not dict_data: + raise KeyringInvalidPassword() + try: + self._data = pickle.loads(dict_data) + except pickle.UnpicklingError: + raise KeyringInvalidPassword() + else: + self._data = {} + + self.is_open = True + + def close(self): + """See :meth:`KeyringManager.close`""" + del self._data + self.is_open = False + + def get(self, uuid): + """See :meth:`KeyringManager.get_secret`""" + if uuid not in self._data: + raise KeyringNoMatchError() + + return self._data[uuid] + + def update(self, uuid, conn_id, secrets, update=True): + """See :meth:`KeyringManager.update_secret`""" + if update: + self._data[uuid] = secrets + + def delete(self, uuid): + """See :meth:`KeyringManager.delete_secret`""" + if uuid not in self._data: + raise KeyringNoMatchError() + + del self._data[uuid] + + def write(self): + """See :meth:`KeyringManager.write`""" + dict_data = pickle.dumps(self._data, protocol=-1) + mode, orig_len, enc_data = self._aes.encrypt(dict_data, CBC, + self._key, SIZE_128, + self._iv) + self.set_value(os.path.join(self.path, 'data'), enc_data) + self.set_value(os.path.join(self.path, 'orig_len'), orig_len) + self.client.suggest_sync() + diff --git a/wader/common/middleware.py b/wader/common/middleware.py new file mode 100644 index 0000000..06c7750 --- /dev/null +++ b/wader/common/middleware.py @@ -0,0 +1,929 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Wrapper around :class:`~wader.common.protocol.WCDMAProtocol` + +It basically provides error control and more high-level operations. +N-tier folks can see this as a Business Logic class. +""" + +from collections import deque + +import serial +from twisted.python import log +from twisted.internet import defer, reactor + +import wader.common.aterrors as E +from wader.common.consts import MM_IP_METHOD_STATIC +from wader.common.contact import Contact +from wader.common.encoding import (from_ucs2, from_u, unpack_ucs2_bytes, + pack_ucs2_bytes, check_if_ucs2) +import wader.common.exceptions as ex +from wader.common.protocol import WCDMAProtocol +from wader.common.sim import RETRY_ATTEMPTS, RETRY_TIMEOUT +from wader.common.sms import pdu_to_message, message_to_pdu + +HSO_MAX_RETRIES = 10 +HSO_RETRY_TIMEOUT = 3 + +def regexp_to_contact(match): + """ + Returns a :class:`wader.common.contact.Contact` out of ``match`` + + :type match: ``re.MatchObject`` + """ + name = from_ucs2(match.group('name')) + number = from_ucs2(match.group('number')) + index = int(match.group('id')) + return Contact(name, number, index=index) + + +class WCDMAWrapper(WCDMAProtocol): + """ + I am a wrapper around :class:`~wader.common.protocol.WCDMAProtocol` + + Its main objective is to provide some error control on some operations + and a cleaner API to deal with its results. + """ + + def __init__(self, device): + super(WCDMAWrapper, self).__init__(device) + # unfortunately some methods require me to save some state + # between runs. This dict contains 'em. + self.state_dict = {} + + def __str__(self): + return self.device.__remote_name__ + + def add_contact(self, contact): + """ + Adds ``contact`` to the SIM and returns the index where was stored + """ + name = from_u(contact.name) + + if 'UCS2' in self.device.sim.charset: + name = pack_ucs2_bytes(name) + + # common arguments for both operations (name and number) + args = [name, from_u(contact.number)] + + if contact.index: + # contact.index is set, user probably wants to overwrite an + # existing contact + args.append(contact.index) + d = super(WCDMAWrapper, self).add_contact(*args) + d.addCallback(lambda _: contact.index) + return d + + # contact.index is not set, this means that we need to obtain the + # first free slot on the phonebook and then add the contact + def get_next_id_cb(index): + args.append(index) + d2 = super(WCDMAWrapper, self).add_contact(*args) + # now we just fake add_contact's response and we return the index + d2.addCallback(lambda _: index) + return d2 + + d = self._get_next_contact_id() + d.addCallback(get_next_id_cb) + return d + + def add_sms(self, sms): + """Adds ``sms`` to the SIM archive""" + pdu_len = sms.get_pdu_len() + d = super(WCDMAWrapper, self).add_sms(pdu_len, sms.get_pdu()) + d.addCallback(lambda resp: int(resp[0].group('id'))) + return d + + def change_pin(self, oldpin, newpin): + """Changes PIN from ``oldpin`` to ``newpin``""" + d = super(WCDMAWrapper, self).change_pin(oldpin, newpin) + d.addCallback(lambda result: result[0].group('resp')) + return d + + def check_pin(self): + """ + Returns the SIM's auth state + + :raise SimPinRequired: Raised if SIM PIN is required + :raise SimPukRequired: Raised if SIM PUK is required + :raise SimPuk2Required: Raised if SIM PUK2 is required + """ + d = super(WCDMAWrapper, self).check_pin() + def process_result(resp): + result = resp[0].group('resp') + if result == 'READY': + return result + elif result == 'SIM PIN': + raise E.SimPinRequired() + elif result == 'SIM PUK': + raise E.SimPukRequired() + elif result == 'SIM PUK2': + raise E.SimPuk2Required() + else: + log.err("unknown authentication state %s" % result) + + d.addCallback(process_result) + return d + + def delete_contact(self, index): + """Deletes contact at ``index``""" + d = super(WCDMAWrapper, self).delete_contact(index) + d.addCallback(lambda result: result[0].group('resp')) + return d + + def delete_sms(self, index): + """Deletes SMS at ``index``""" + d = super(WCDMAWrapper, self).delete_sms(index) + d.addCallback(lambda result: result[0].group('resp')) + return d + + def disable_echo(self): + """Disables echo""" + d = super(WCDMAWrapper, self).disable_echo() + d.addCallback(lambda result: result[0].group('resp')) + return d + + def enable_pin(self, pin, enable): + """ + Enables or disables PIN auth with ``pin`` according to ``enable`` + """ + d = super(WCDMAWrapper, self).enable_pin(pin, enable) + d.addCallback(lambda result: result[0].group('resp')) + return d + + def enable_echo(self): + """ + Enables echo + + Use this with caution as it might leave Wader on an unusable state + """ + d = super(WCDMAWrapper, self).enable_echo() + d.addCallback(lambda result: result[0].group('resp')) + return d + + def find_contacts(self, pattern): + """ + Returns all the `Contact` objects whose name matches ``pattern`` + + :rtype: list + """ + if 'UCS2' in self.device.sim.charset: + pattern = pack_ucs2_bytes(pattern) + + d = super(WCDMAWrapper, self).find_contacts(pattern) + d.addCallback(lambda matches: map(regexp_to_contact, matches)) + return d + + def get_apns(self): + """ + Returns all the APNs in the SIM + + :rtype: list + """ + d = super(WCDMAWrapper, self).get_apns() + d.addCallback(lambda resp: + [(int(r.group('index')), r.group('apn')) for r in resp]) + return d + + def get_band(self): + """Returns the current band used""" + raise NotImplementedError() + + def get_bands(self): + """ + Returns the available bands + + :rtype: list + """ + if not self.custom or not self.custom.band_dict: + raise AttributeError("No band dict registered for this device") + + return defer.succeed(sorted(self.custom.band_dict.keys())) + + def get_card_model(self): + """Returns the card model""" + d = super(WCDMAWrapper, self).get_card_model() + d.addCallback(lambda response: response[0].group('model')) + return d + + def get_card_version(self): + """Returns the firmware version""" + d = super(WCDMAWrapper, self).get_card_version() + d.addCallback(lambda response: response[0].group('version')) + return d + + def get_charset(self): + """Returns the current charset""" + d = super(WCDMAWrapper, self).get_charset() + d.addCallback(lambda response: response[0].group('lang')) + return d + + def get_charsets(self): + """ + Returns the available charsets + + :rtype: list + """ + d = super(WCDMAWrapper, self).get_charsets() + d.addCallback(lambda resp: [match.group('lang') for match in resp]) + return d + + def get_contact_by_index(self, index): + """Returns the contact at ``index``""" + d = super(WCDMAWrapper, self).get_contact_by_index(index) + d.addCallback(lambda match: regexp_to_contact(match[0])) + return d + + def get_contacts(self): + """ + Returns all the contacts in the SIM + + :rtype: list + """ + def not_found_eb(failure): + failure.trap(E.NotFound, E.InvalidIndex, E.GenericError) + return [] + + def get_them(ignored=None): + d = super(WCDMAWrapper, self).get_contacts() + d.addCallback(lambda matches: map(regexp_to_contact, matches)) + d.addErrback(not_found_eb) + return d + + if self.device.sim.size: + return get_them() + else: + d = self._get_next_contact_id() + d.addCallback(get_them) + return d + + def get_hardware_info(self): + """Returns the manufacturer name, card model and firmware version""" + dlist = [self.get_manufacturer_name(), + self.get_card_model(), + self.get_card_version()] + + return defer.gatherResults(dlist) + + def get_imei(self): + """Returns the IMEI""" + d = super(WCDMAWrapper, self).get_imei() + d.addCallback(lambda response: response[0].group('imei')) + return d + + def get_imsi(self): + """Returns the IMSI""" + d = super(WCDMAWrapper, self).get_imsi() + d.addCallback(lambda response: response[0].group('imsi')) + return d + + def get_ip4_config(self): + """Returns the IP4Config info related to IpMethod""" + if self.device.sconn.props['IpMethod'] == MM_IP_METHOD_STATIC: + return self.hso_get_ip4_config() + + # XXX: implement DHCP too once we get a new sonyericsson + return defer.succeed(['0.0.0.0'] * 4) + + def get_manufacturer_name(self): + """Returns the manufacturer name""" + d = super(WCDMAWrapper, self).get_manufacturer_name() + d.addCallback(lambda response: response[0].group('name')) + return d + + def get_netreg_info(self): + """Get the registration status and the current operator""" + # Ugly but it works. The naive approach with DeferredList won't work + # as the call order is not guaranteed + resp = [] + d = self.get_netreg_status() + d.addCallback(lambda info: resp.append(info[1])) + d.addCallback(lambda _: self.set_network_info_format(3, 2)) + d.addCallback(lambda _: self.get_network_info()) + d.addCallback(lambda info: resp.append(info[0])) + def get_netinfo_eb(failure): + failure.trap(E.NoNetwork) + resp.append("0") + + d.addErrback(get_netinfo_eb) + d.addCallback(lambda _: self.set_network_info_format(3, 0)) + d.addCallback(lambda _: self.get_network_info()) + d.addCallback(lambda info: resp.append(info[0])) + d.addErrback(get_netinfo_eb) + d.addCallback(lambda _: tuple(resp)) + return d + + def get_netreg_status(self): + """Returns a tuple with the network registration status""" + d = super(WCDMAWrapper, self).get_netreg_status() + def get_netreg_status(resp): + # convert them to int + return int(resp[0].group('mode')), int(resp[0].group('status')) + + d.addCallback(get_netreg_status) + return d + + def get_network_info(self): + """ + Returns the network info (a.k.a AT+COPS?) + + The response will be a tuple as (OperatorName, ConnectionType) if + it returns a (None, None) that means that some error occurred while + obtaining the info. The class that requested the info should take + care of insisting before this problem. This method will convert + numeric network IDs to alphanumeric. + """ + d = super(WCDMAWrapper, self).get_network_info() + def get_net_info_cb(netinfo): + """ + Returns a (Networname, ConnType) tuple + + It returns None if there's no info + """ + if not netinfo: + return None + + netinfo = netinfo[0] + + if netinfo.group('error'): + # this means that we've received a response like + # +COPS: 0 which means that we don't have network temporaly + # we should raise an exception here + raise E.NoNetwork() + + status = int(netinfo.group('status')) + conn_type = (status == 0) and 'GPRS' or '3G' + netname = netinfo.group('netname') + + if netname in ['Limited Service', + pack_ucs2_bytes('Limited Service')]: + raise ex.LimitedServiceNetworkError + + # netname can be in UCS2, as a string, or as a network id (int) + if check_if_ucs2(netname): + return unpack_ucs2_bytes(netname), conn_type + else: + # now can be either a string or a network id (int) + try: + netname = int(netname) + except ValueError: + # we got a string ID + return netname, conn_type + + # if we have arrived here, that means that the network id + # is a five digit integer + return str(netname), conn_type + + d.addCallback(get_net_info_cb) + return d + + def get_network_mode(self): + """Returns the current network mode""" + raise NotImplementedError() + + def get_network_names(self): + """ + Performs a network search + + :rtype: list of :class:`NetworkOperator` + """ + d = super(WCDMAWrapper, self).get_network_names() + d.addCallback(lambda resp: + [NetworkOperator(*match.groups()) for match in resp]) + return d + + def _get_free_contact_ids(self): + """Returns a deque with the not used contact ids""" + def get_contacts_cb(contacts): + if not contacts: + return deque(range(1, self.device.sim.size)) + + busy_ids = [contact.index for contact in contacts] + free = set(range(1, self.device.sim.size)) ^ set(busy_ids) + return deque(list(free)) + + def get_contacts_eb(failure): + failure.trap(E.NotFound, E.GenericError) + return deque(range(1, self.device.sim.size)) + + d = self.get_contacts() + d.addCallbacks(get_contacts_cb, get_contacts_eb) + return d + + def _get_next_contact_id(self): + """Returns the next unused contact id""" + # provide some error control and don't fail if sim.size + # is None, the card might be a bit difficult + def do_get_it(): + d = self._get_free_contact_ids() + d.addCallback(lambda free: free.popleft()) + return d + + if self.device.sim.size and self.device.sim.size != 0: + return do_get_it() + + deferred = defer.Deferred() + self.state_dict['phonebook_retries'] = 0 + + def get_it(auxdef=None): + def get_phonebook_size_cb(size): + self.device.sim.size = size + d = do_get_it() + d.chainDeferred(deferred) + + def get_phonebook_size_eb(failure): + self.state_dict['phonebook_retries'] += 1 + if self.state_dict['phonebook_retries'] > RETRY_ATTEMPTS: + raise RuntimeError("Could not obtain phonebook size") + + reactor.callLater(RETRY_TIMEOUT, get_it, auxdef) + + d = self.get_phonebook_size() + d.addCallback(get_phonebook_size_cb) + d.addErrback(get_phonebook_size_eb) + + return auxdef + + return get_it(deferred) + + def get_phonebook_size(self): + """Returns the phonebook size""" + d = super(WCDMAWrapper, self).get_phonebook_size() + d.addCallback(lambda resp: int(resp[0].group('size'))) + return d + + def get_pin_status(self): + """Returns 1 if PIN auth is active and 0 if its not""" + def pinreq_errback(failure): + failure.trap(E.SimPinRequired) + return 1 + + def aterror_eb(failure): + failure.trap(E.GenericError) + # return the failure or wont work + return failure + + d = super(WCDMAWrapper, self).get_pin_status() + d.addCallback(lambda response: int(response[0].group('status'))) + d.addErrback(pinreq_errback) + d.addErrback(aterror_eb) + + return d + + def get_radio_status(self): + """Returns whether the radio is enabled or disabled""" + d = super(WCDMAWrapper, self).get_radio_status() + d.addCallback(lambda resp: bool(int(resp[0].group('status')))) + return d + + def get_roaming_ids(self): + """Returns the network ids stored in the SIM to roam""" + # a.k.a. AT+CPOL? + d = super(WCDMAWrapper, self).get_roaming_ids() + d.addCallback(lambda raw: + [BasicNetworkOperator(obj.group('netid')) for obj in raw]) + return d + + def get_signal_quality(self): + """Returns the signal level quality""" + d = super(WCDMAWrapper, self).get_signal_quality() + d.addCallback(lambda response: int(response[0].group('rssi'))) + return d + + def get_sms(self): + """ + Returns all the SMS in the SIM card + + :rtype: list + """ + d = super(WCDMAWrapper, self).get_sms() + def get_all_sms_cb(messages): + sms_list = [] + for rawsms in messages: + # Message obj + try: + sms = pdu_to_message(rawsms.group('pdu')) + sms.index = int(rawsms.group('id')) + sms.where = int(rawsms.group('where')) + sms_list.append(sms) + except ValueError: + log.err(ex.MalformedSMSError, + "Malformed PDU: %s" % rawsms.group('pdu')) + return sms_list + + d.addCallback(get_all_sms_cb) + return d + + def get_sms_by_index(self, index): + """ + Returns a ``Message`` object representing the SMS at ``index`` + """ + d = super(WCDMAWrapper, self).get_sms_by_index(index) + def get_sms_cb(rawsms): + try: + sms = pdu_to_message(rawsms[0].group('pdu')) + sms.index = index + sms.where = int(rawsms[0].group('where')) + except IndexError: + # handle bogus CMTI notifications, see #180 + return None + + return sms + + d.addCallback(get_sms_cb) + return d + + def get_sms_format(self): + """ + Returns 1 if SMS format is text and 0 if SMS format is PDU + """ + d = super(WCDMAWrapper, self).get_sms_format() + d.addCallback(lambda response: int(response[0].group('format'))) + return d + + def get_smsc(self): + """Returns the SMSC number stored in the SIM""" + d = super(WCDMAWrapper, self).get_smsc() + def get_smsc_cb(response): + try: + smsc = response[0].group('smsc') + if not smsc.startswith('+'): + if check_if_ucs2(smsc): + smsc = from_u(unpack_ucs2_bytes(smsc)) + + return smsc + except KeyError: + raise E.NotFound() + + d.addCallback(get_smsc_cb) + return d + + def hso_authenticate(self, user, passwd): + """Authenticates using ``user`` and ``passwd`` on HSO devices""" + conn_id = self.state_dict['conn_id'] + d = super(WCDMAWrapper, self).hso_authenticate(conn_id, user, passwd) + d.addCallback(lambda resp: resp[0].group('resp')) + return d + + def _hso_get_ip4_config(self): + """Returns the ip4 config on a HSO device""" + d = super(WCDMAWrapper, self).hso_get_ip4_config() + def hso_get_ip4_config_cb(resp): + ip = resp[0].group('ip') + dns1, dns2 = resp[0].group('dns1'), resp[0].group('dns2') + # dns3 is a dummy value for now + dns3 = '0.0.0.0' + return [ip, dns1, dns2, dns3] + + d.addCallback(hso_get_ip4_config_cb) + return d + + def hso_get_ip4_config(self): + """ + Returns the ip4 config on a HSO device + + Wrapper around _hso_get_ip4_config that provides some error control + """ + self.state_dict['retry_call'] = None + self.state_dict['num_of_retries'] = 0 + + def get_ip4_config(deferred): + def get_ip4_eb(failure): + failure.trap(E.GenericError) + self.state_dict['num_of_retries'] += 1 + if self.state_dict['num_of_retries'] > HSO_MAX_RETRIES: + return failure + + self.state_dict['retry_call'] = reactor.callLater( + HSO_RETRY_TIMEOUT, + get_ip4_config, deferred) + + d = self._hso_get_ip4_config() + d.addCallback(deferred.callback) + d.addErrback(get_ip4_eb) + return deferred + + auxdef = defer.Deferred() + return get_ip4_config(auxdef) + + def save_sms(self, sms): + """ + Stores ``sms`` and returns a list of indexes + + ``sms`` might span several messages if it is a multipart SMS + """ + ret = [] + for pdu_len, pdu in message_to_pdu(sms, store=True): + d = super(WCDMAWrapper, self).save_sms(pdu, pdu_len) + d.addCallback(lambda response: response[0].group('index')) + ret.append(d) + + return defer.gatherResults(ret) + + def send_at(self, atstr, name='send_at', callback=None): + """Sends an arbitrary AT string ``atstr``""" + d = super(WCDMAWrapper, self).send_at(atstr, name=name) + if callback is None: + d.addCallback(lambda response: response[0].group('resp')) + else: + d.addCallback(callback) + + return d + + def send_pin(self, pin): + """ + Sends ``pin`` to authenticate + + Most devices need some time to settle after a successful auth + it is the caller's responsability to give at least 15 seconds + to the device to settle, this time varies from device to device + """ + d = super(WCDMAWrapper, self).send_pin(pin) + d.addCallback(lambda response: response[0].group('resp')) + return d + + def send_puk(self, puk, pin): + """ + Send ``puk`` and ``pin`` to authenticate + + Most devices need some time to settle after a successful auth + it is the caller's responsability to give at least 15 seconds + to the device to settle, this time varies from device to device + """ + d = super(WCDMAWrapper, self).send_puk(puk, pin) + d.addCallback(lambda response: response[0].group('resp')) + return d + + def send_sms(self, sms): + """ + Sends ``sms`` and returns the indexes + + ``sms`` might span several messages if it is a multipart SMS + """ + ret = [] + for pdu_len, pdu in message_to_pdu(sms): + d = super(WCDMAWrapper, self).send_sms(pdu, pdu_len) + d.addCallback(lambda response: int(response[0].group('index'))) + ret.append(d) + + return defer.gatherResults(ret) + + def send_sms_from_storage(self, index): + """Sends the SMS stored at ``index`` and returns the new index""" + d = super(WCDMAWrapper, self).send_sms_from_storage(index) + d.addCallback(lambda response: int(response[0].group('index'))) + return d + + def set_apn(self, apn): + """Sets the APN to ``apn``""" + def process_apns(apns): + state = self.state_dict + for _index, _apn in apns: + if apn == _apn: + state['conn_id'] = _index + return + + state['conn_id'] = len(apns) + 1 + d = super(WCDMAWrapper, self).set_apn(state['conn_id'], apn) + d.addCallback(lambda response: response[0].group('resp')) + return d + + d = self.get_apns() + d.addCallback(process_apns) + return d + + def set_band(self, band): + """Sets the device band to ``band``""" + raise NotImplementedError() + + def set_charset(self, charset): + """Sets the SIMs charset to ``charset``""" + d = super(WCDMAWrapper, self).set_charset(charset) + d.addCallback(lambda ignored: self.device.sim.set_charset(charset)) + return d + + def set_network_mode(self, mode): + """Sets the network mode to ``mode``""" + raise NotImplementedError() + + def enable_radio(self, enable): + """ + Enables the radio according to ``enable`` + + It will not enable it if its already enabled and viceversa + """ + def check_if_necessary(status): + if (status and enable) or (not status and not enable): + return defer.succeed('OK') + + d = super(WCDMAWrapper, self).enable_radio(enable) + d.addCallback(lambda response: response[0].group('resp')) + return d + + d = self.get_radio_status() + d.addCallback(check_if_necessary) + return d + + def set_sms_format(self, _format=0): + """Sets PDU mode or text mode in the SIM""" + d = super(WCDMAWrapper, self).set_sms_format(_format) + d.addCallback(lambda response: response[0].group('resp')) + return d + + def set_smsc(self, smsc): + """Sets the SIMS's SMSC number to ``smsc``""" + if 'UCS2' in self.device.sim.charset: + smsc = pack_ucs2_bytes(smsc) + d = super(WCDMAWrapper, self).set_smsc(smsc) + d.addCallback(lambda response: response[0].group('resp')) + return d + + # some high-level methods exported over DBus + def get_simple_status(self): + """Returns the status for o.fd.MM.Modem.Simple.GetStatus""" + def get_simple_status_cb((rssi, netinfo, band, net_mode)): + return dict(signal_quality=rssi, + operator_code=netinfo[1], + operator_name=netinfo[2], + band=band) + + deferred_list = [] + deferred_list.append(self.get_signal_quality()) + deferred_list.append(self.get_netreg_info()) + deferred_list.append(self.get_band()) + deferred_list.append(self.get_network_mode()) + + d = defer.gatherResults(deferred_list) + d.addCallback(get_simple_status_cb) + d.addErrback(log.err) + return d + + def connect_simple(self, settings): + """Connects with the given ``settings``""" + simplesm = self.device.custom.simp_klass(self.device, settings) + d = simplesm.start_simple() + return d + + def connect_to_internet(self, number): + """Opens data port and dials ``number`` in""" + port = self.device.ports.dport + if port.obj is not None: + raise AttributeError("Data serial port is not None") + + # open the data port + port.obj = serial.Serial(port.path) + port.obj.flush() + + d = defer.maybeDeferred(port.obj.write, "ATDT%s\r\n" % number) + return d + + def disconnect_from_internet(self): + """Disconnects the modem temporally lowering the DTR""" + port = self.device.ports.dport + if port.obj is None: + raise AttributeError("Data serial port is None") + + d = defer.Deferred() + def restore_speed(orig_speed): + port.obj.setBaudrate(orig_speed) + d.callback(True) + + speed = port.obj.getBaudrate() + reactor.callLater(.1, restore_speed, speed) + return d + + def register_with_netid(self, netid): + """ + I will try my best to register with ``netid`` + + If ``netid`` is an empty string, I will register with my home network + """ + netr_klass = self.device.custom.netr_klass + netsm = netr_klass(self.device.sconn, netid) + return netsm.start_netreg() + + def enable_device(self, enable): + """ + I enable or disable myself according to ``enable`` + + If enable is True, I check the auth state of a device and will try to + initialize it. Otherwise I will disable myself + """ + if enable: + return self._do_enable_device() + else: + return self._do_disable_device() + + def _do_disable_device(self): + if not self.device.enabled: + msg = "Can not disable a not enabled device" + return defer.fail(Exception(msg)) + + d = self.device.sconn.enable_radio(False) + d.addCallback(lambda _: self.device.close(remove_from_conn=False)) + return d + + def _do_enable_device(self): + from wader.common.startup import attach_to_serial_port + if self.device.enabled: + # if a device was enabled and then disabled, there's no + # need to check the authentication again + if self.device.ports.cport.obj is None: + d = attach_to_serial_port(self.device) + d.addCallback(self._initialize_cb) + else: + d = defer.succeed(self.device) + + return d + + def process_device_and_initialize(device): + self.device = device + auth_klass = self.device.custom.auth_klass + authsm = auth_klass(self.device) + d = authsm.start_auth() + # if auth is ready, the device will initialize straight away + # if auth aint ready the callback chain wont be executed and + # will just return the given exception + d.addCallback(self.device.initialize) + d.addCallback(self._initialize_cb) + return d + + d = attach_to_serial_port(self.device) + d.addCallback(process_device_and_initialize) + return d + + def _initialize_cb(self, size=None): + d = self.device.sconn.enable_radio(True) + d.addCallback(lambda _: + self.device.exporter.DeviceEnabled(self.device.udi)) + d.addCallback(lambda _: setattr(self.device, 'enabled', True)) + d.addCallback(lambda _: size) + return d + + def _check_initted_device(self, result): + """ + Upon successful auth over DBus I'll check if the device was initted + """ + if self.device.sim and self.device.sim.initted: + log.msg("device was already initted, just returning orig result") + return result + + DELAY = self.device.custom.auth_klass.DELAY + log.msg("giving the device %d seconds to settle, waiting..." % DELAY) + + deferred = defer.Deferred() + def do_init(): + d = self.device.initialize() + d.addCallback(self._initialize_cb) + d.addCallback(lambda size: deferred.callback(size)) + + reactor.callLater(DELAY, do_init) + return deferred + + +class BasicNetworkOperator(object): + """A Network operator with a netid""" + def __init__(self, netid): + super(BasicNetworkOperator, self).__init__() + self.netid = netid + + def __repr__(self): + return '' % self.netid + + def __eq__(self, o): + return self.netid == o.netid + + def __ne__(self, o): + return not self.__eq__(o) + + +class NetworkOperator(BasicNetworkOperator): + """I represent a network operator on a mobile network""" + def __init__(self, stat, long_name, short_name, netid, rat): + super(NetworkOperator, self).__init__(netid) + self.stat = int(stat) + self.long_name = from_ucs2(long_name) + self.short_name = from_ucs2(short_name) + self.rat = int(rat) + + def __repr__(self): + args = (self.long_name, self.netid) + return '' % args + diff --git a/wader/common/netspeed.py b/wader/common/netspeed.py new file mode 100644 index 0000000..335f3de --- /dev/null +++ b/wader/common/netspeed.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Utilities to measure network speed in an hardware agnostic way""" + +from math import floor +from time import time + +from twisted.internet import task, defer + +def bps_to_human(up, down): + """ + Converts ``up`` and ``down`` from bits per second to human + + :param up: The upload speed in bits per second + :param down: The download speed in bits per second + :rtype: tuple + """ + if up > 1000: + upspeed = up / 1000.0 + downspeed = down / 1000.0 + upmsg = (upspeed > 1000) and "%3.2f Mbps" % (upspeed / 1000) or \ + "%3.2f Kbps" % upspeed + downmsg = (downspeed > 1000) and "%3.2f Mbps" % (downspeed / 1000) \ + or "%3.2f Kbps" % downspeed + else: + upmsg = "%3.2f bps" % up + downmsg = "%3.2f bps" % down + + return upmsg, downmsg + +class NetworkSpeed(object): + """Class to measure network speed""" + + INTERVAL = .5 + + def __init__(self): + self.speed = {'down': 0.0, 'up': 0.0} + self.loop = task.LoopingCall(self.compute_stats) + self.mutex = defer.DeferredLock() + self._time = None + self._inbits = 0 + self._outbits = 0 + + def __getitem__(self, key): + if not key in self.speed: + raise IndexError("key %s not in %s" % (key, self.speed)) + + return self.speed[key] + + def start(self): + """Starts the measurement""" + self.loop.start(self.INTERVAL, now=False) + self._time = time() + + def stop(self): + """Stops the measurement""" + self.loop.stop() + + def compute_stats(self): + """Extracts and computes the number of bytes recv/sent""" + from wader.common.oal import osobj + d = osobj.get_iface_stats() + d.addCallback(self.update_stats) + + def update_stats(self, (inbits, outbits)): + """Updates the stats with parse_input's result""" + # Inspired by Gdesklet's Net.py module + if inbits is None or outbits is None: + return + + if not self._inbits or not self._outbits: + self._inbits = inbits + self._outbits = outbits + + def doit(ignored): + now = time() + interval = now - self._time + + in_diff = inbits - self._inbits + out_diff = outbits - self._outbits + + self.speed['down'] = int(floor(in_diff / interval)) + self.speed['up'] = int(floor(out_diff / interval)) + + self._inbits = inbits + self._outbits = outbits + self._time = now + self.mutex.release() + + d = self.mutex.acquire() + d.addCallback(doit) + diff --git a/wader/common/oal.py b/wader/common/oal.py new file mode 100644 index 0000000..99454c0 --- /dev/null +++ b/wader/common/oal.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +OS Abstraction Layer + +OS provides an abstraction layer so path differences between OSes/distros +won't affect Wader +""" + +def get_os_object(): + """ + Returns a ``OSPlugin`` instance corresponding to current OS used + + If the OS is unknown it will return None + """ + from wader.common.plugin import PluginManager + from wader.common.interfaces import IOSPlugin + + for osplugin in PluginManager.get_plugins(IOSPlugin): + if osplugin.is_valid(): + osplugin.initialize() + return osplugin + + return None + +osobj = get_os_object() + diff --git a/wader/common/oses/__init__.py b/wader/common/oses/__init__.py new file mode 100644 index 0000000..b0c2f5e --- /dev/null +++ b/wader/common/oses/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""OSes base classes""" + diff --git a/wader/common/oses/bsd.py b/wader/common/oses/bsd.py new file mode 100644 index 0000000..778a13c --- /dev/null +++ b/wader/common/oses/bsd.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""BSD-based OS plugins""" + +from wader.common.oses.unix import UnixPlugin + +class FreeBSDPlugin(UnixPlugin): + """Plugin for FreeBSD""" + + def __init__(self): + super(FreeBSDPlugin, self).__init__() + + def is_valid(self): + try: + __import__("freebsd") + return True + except ImportError: + return False + + +class OpenBSDPlugin(UnixPlugin): + """Plugin for OpenBSD""" + + def __init__(self): + super(OpenBSDPlugin, self).__init__() + + def is_valid(self): + try: + __import__("openbsd") + return True + except ImportError: + return False + + diff --git a/wader/common/oses/linux.py b/wader/common/oses/linux.py new file mode 100644 index 0000000..752f958 --- /dev/null +++ b/wader/common/oses/linux.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Linux-based OS plugin""" + +from time import time +from os.path import join, exists + +import serial +from zope.interface import implements +from twisted.internet import defer, reactor, utils, error +from twisted.python import log + +from wader.common._dbus import DBusComponent +from wader.common.interfaces import IHardwareManager +from wader.common.plugin import PluginManager +from wader.common import consts +from wader.common.oses.unix import UnixPlugin +from wader.common.utils import get_file_data, natsort +from wader.common.startup import setup_and_export_device +from wader.common.serialport import Ports + +IDLE, BUSY = range(2) +ADD_THRESHOLD = 10. + +def probe_port(port): + """ + Check whether `port` exists and works + + :rtype: bool + """ + try: + ser = serial.Serial(port, timeout=.01) + try: + ser.write('AT+CGMR\r\n') + except OSError, e: + log.err(e, "Error identifying device in port %s" % port) + return False + if not ser.readline(): + # Huawei E620 with driver option registers three serial + # ports and the middle one wont raise any exception while + # opening it even thou its a dummy port. + return False + + return True + except serial.SerialException, e: + return False + finally: + if 'ser' in locals(): + ser.close() + + +def probe_ports(ports): + """ + Obtains the data and control ports out of ``ports`` + + :rtype: tuple + """ + dport = cport = None + while ports: + port = ports.pop(0) + if probe_port(port): + if dport is None: + # data port tends to the be the first one + dport = port + elif cport is None: + # control port the next one + cport = port + break + + return dport, cport + +def extract_info(props): + """ + Extracts the bus-related information from Hal's ``props`` + + :param props: Hal dict + :rtype: dict + """ + info = {} + if 'usb.vendor_id' in props: + info['usb_device.vendor_id'] = props['usb.vendor_id'] + info['usb_device.product_id'] = props['usb.product_id'] + elif 'usb_device.vendor_id' in props: + info['usb_device.vendor_id'] = props['usb_device.vendor_id'] + info['usb_device.product_id'] = props['usb_device.product_id'] + elif 'pcmia.manf_id' in props: + info['pcmcia.manf_id'] = props['pcmcia.manf_id'] + info['pcmcia.card_id'] = props['pcmcia.card_id'] + elif 'pci.vendor_id' in props: + info['pci.vendor_id'] = props['pci.vendor_id'] + info['pci.product_id'] = props['pci.product_id'] + else: + raise RuntimeError("Unknown bus for device %s" % props['info.udi']) + + return info + + +class HardwareManager(DBusComponent): + """ + I find and configure devices on Linux + + I am resilient to ports assigned in unusual locations + and devices sharing ids. + """ + implements(IHardwareManager) + + def __init__(self): + super(HardwareManager, self).__init__() + #: dictionary with all my configured clients + self.clients = {} + #: reference to StartupController + self.controller = None + self.mode = IDLE + # list with the added udis during a hotplugging event + self.added_udis = [] + # last time of an action + self.last_action = None + self.call_id = None + # list of waiting get_devices petitions + self._waiting = [] + + self._connect_to_signals() + + def _connect_to_signals(self): + self.manager.connect_to_signal('DeviceAdded', self._dev_added_cb) + self.manager.connect_to_signal('DeviceRemoved', self._dev_removed_cb) + + def register_controller(self, controller): + """ + See :meth:`wader.common.interfaces.IHardwareManager.register_controller` + """ + self.controller = controller + + def get_devices(self): + """See :meth:`wader.common.interfaces.IHardwareManager.get_devices`""" + # only enter here if its the very first time + if self.mode == IDLE and not self.clients: + self.mode = BUSY + parent_udis = self._get_parent_udis() + d = self._get_devices_from_udis(parent_udis) + d.addCallback(self._check_if_devices_are_registered) + return d + + elif self.mode == IDLE: + return defer.succeed(self.clients.values()) + + # we are waiting for an on-going process started already + # we queue the request and will be callbacked when the + # data is ready + d = defer.Deferred() + self._waiting.append(d) + return d + + def _transition_to_idle(self, ignored=None): + self.mode = IDLE + + def _get_device_from_udi(self, udi): + """Returns a device built out of the info extracted from ``udi``""" + context = self._get_child_udis_from_udi(udi) + info = extract_info(self.get_properties_from_udi(udi)) + ports = self._get_ports_from_udi(udi, context=context) + device = self._get_device_from_info_and_ports(info, udi, ports, + context=context) + return device + + def _get_devices_from_udis(self, udis): + """ + Returns a list of devices built out of the info extracted from ``udis`` + """ + from wader.common.hardware.base import identify_device + unknown_devs = map(self._get_device_from_udi, udis) + deferreds = map(identify_device, unknown_devs) + return defer.gatherResults(deferreds) + + def _get_modem_path(self, dev_udi): + """Returns the object path of the modem device child of ``dev_udi``""" + def is_child_of(parent_udi, child_udi): + cur_udi = child_udi + while 1: + try: + p = self.get_properties_from_udi(cur_udi)['info.parent'] + if p == parent_udi: + return True + except KeyError: + return False + else: + cur_udi = p + + for udi in self.manager.FindDeviceByCapability("modem"): + if is_child_of(dev_udi, udi): + return udi + + raise RuntimeError("Couldn't find the modem path of %s" % dev_udi) + + def _get_driver_name(self, udi, context=None): + """Returns the info.linux.driver of `udi`""" + def do_get_driver_name(key, _udi, props): + if key in props[_udi]: + name = props[_udi][key] + if name not in ['usb', 'pci', 'pcmcia']: + return name + + if context: + childs, device_props = context + else: + childs, device_props = self._get_child_udis_from_udi(udi) + + # extend the list of childs with the parent udi itself, devices + # such as Option Nozomi won't work otherwise + childs.extend([udi]) + + for _udi in childs: + name = do_get_driver_name('info.linux.driver', _udi, device_props) + if name: + return name + + raise RuntimeError("Could not find the driver name of device %s" % udi) + + def _get_network_device(self, udi, context=None): + if context: + childs, dp = context + else: + childs, dp = self._get_child_udis_from_udi(udi) + + for _udi in childs: + properties = dp[_udi] + if 'net.interface' in properties: + return properties['net.interface'] + + raise KeyError("Couldn't find net.interface in device %s" % udi) + + def _get_parent_udis(self): + """Returns the root udi of all the devices with modem capabilities""" + return set(map(self._get_parent_udi, + self.manager.FindDeviceByCapability("modem"))) + + def _get_parent_udi(self, udi): + """Returns the absolute parent udi of ``udi``""" + OD = 'serial.originating_device' + def get_parent(props): + return (props[OD] if OD in props else props['info.parent']) + + current_udi = udi + while 1: + properties = self.get_properties_from_udi(current_udi) + try: + info = extract_info(properties) + break + except RuntimeError: + current_udi = get_parent(properties) + + # now that we have an id to lookup for, lets repeat the process till we + # get another RuntimeError + def find_out_if_contained(_info, properties): + """ + Returns `True` if `_info` values are contained in `props` + + As hal likes to swap between usb.vendor_id and usb_device.vendor_id + I have got a special case where I will retry + """ + def compare_dicts(d1, d2): + for key in d1: + try: + return d1[key] == d2[key] + except KeyError: + return False + + if compare_dicts(_info, properties): + # we got a straight map + return True + + # hal likes to swap between usb_device.vendor_id and usb.vendor_id + if 'usb_device.vendor_id' in _info: + # our last chance, perhaps its swapped + newinfo = {'usb.vendor_id' : _info['usb_device.vendor_id'], + 'usb.product_id' : _info['usb_device.product_id']} + return compare_dicts(newinfo, properties) + + # the original compare_dicts failed, so return False + return False + + last_udi = current_udi + while 1: + properties = self.get_properties_from_udi(current_udi) + if not find_out_if_contained(info, properties): + break + + last_udi, current_udi = current_udi, get_parent(properties) + + return last_udi + + def _check_if_devices_are_registered(self, devices, to_idle=True): + for device in devices: + if device.udi not in self.clients: + self._register_client(device, device.udi, emit=True) + + for deferred in self._waiting: + deferred.callback(devices) + + self._waiting = [] + + if to_idle: + # back to IDLE + self._transition_to_idle() + + return devices + + def _register_client(self, plugin, udi, emit=False): + """ + Registers `plugin` in `self.clients` by its `udi` + + Will emit a DeviceAdded signal if emit is True + """ + log.msg("registering plugin %s using udi %s" % (plugin, udi)) + self.clients[udi] = setup_and_export_device(plugin) + + if emit: + self.controller.DeviceAdded(udi) + + def _unregister_client(self, udi): + """Removes client identified by ``udi``""" + self.clients[udi].close(remove_from_conn=True) + del self.clients[udi] + + def _dev_added_cb(self, udi): + self.mode = BUSY + self.last_action = time() + + assert udi not in self.added_udis + self.added_udis.append(udi) + + try: + if not self.call_id or self.call_id.called: + self.call_id = reactor.callLater(ADD_THRESHOLD, + self._process_added_udis) + else: + self.call_id.reset(ADD_THRESHOLD) + except (error.AlreadyCalled, error.AlreadyCancelled): + log.err(None, "Error on _device_added_cb") + # call has already been fired + self._cleanup_udis() + + self._transition_to_idle() + + def _dev_removed_cb(self, udi): + if self.mode == BUSY: + # we're in the middle of a hotpluggin event and the udis that + # we just added to self.added_udis are disappearing! + # whats going on? Some devices such as the Huawei E870 will + # add some child udis, and will remove them once libusual kicks + # in, so we need to wait for at most ADD_THRESHOLD seconds + # since the last removal/add to find out what really got added + if udi in self.added_udis: + self.added_udis.remove(udi) + return + + if udi in self.clients: + self._unregister_client(udi) + self.controller.DeviceRemoved(udi) + + def _cleanup_udis(self): + self.added_udis = [] + if self.call_id and not self.call_id.called: + self.call_id.cancel() + + self.call_id = None + + def _process_added_udis(self): + # obtain the parent udis of all the devices with modem capabilities + parent_udis = self._get_parent_udis() + # we're only interested on devices not being handled and just added + not_handled_udis = set(self.clients.keys()) ^ parent_udis + just_added_udis = not_handled_udis & set(self.added_udis) + # get devices out of UDIs and register them emitting DeviceAdded + d = self._get_devices_from_udis(just_added_udis) + d.addCallback(self._check_if_devices_are_registered) + + # cleanup + self._cleanup_udis() + + def _get_hso_ports(self, ports): + dport = cport = None + BASE = '/sys/class/tty' + + for port in ports: + name = port.split('/')[-1] + path = join(BASE, name, 'hsotype') + if not exists(path): + continue + + what = get_file_data(path).strip().lower() + if what == 'modem' and dport is None: + dport = port + elif what == 'application' and cport is None: + cport = port + + if dport and cport: + break + + return dport, cport + + def _get_child_udis_from_udi(self, udi): + """Returns the paths of ``udi`` childs and the properties used""" + device_props = self.get_devices_properties() + dev_udis = sorted(device_props.keys(), key=len) + dev_udis2 = dev_udis[:] + childs = [] + while dev_udis: + _udi = dev_udis.pop() + if _udi != udi and 'info.parent' in device_props[_udi]: + par_udi = device_props[_udi]['info.parent'] + + if par_udi == udi or par_udi in childs: + childs.append(_udi) + + while dev_udis2: + _udi = dev_udis2.pop() + if _udi != udi and 'info.parent' in device_props[_udi]: + par_udi = device_props[_udi]['info.parent'] + + if par_udi == udi or (par_udi in childs and + _udi not in childs): + childs.append(_udi) + + return childs, device_props + + def _get_ports_from_udi(self, parent_udi, context=None): + """Returns all the ports that ``parent_udi`` has registered""" + if context: + childs, dp = context + else: + childs, dp = self._get_child_udis_from_udi(parent_udi) + + if childs: + serial_devs = [dp[_udi]['serial.device'] + for _udi in childs if 'serial.device' in dp[_udi]] + ports = map(str, set(serial_devs)) + natsort(ports) + return ports + + raise RuntimeError("Couldn't find any child of device %s" % parent_udi) + + def _get_device_from_info_and_ports(self, info, root_udi, ports, + context=None): + """Returns a `DevicePlugin` out of ``info`` and ``ports``""" + plugin = PluginManager.get_plugin_by_vendor_product_id(*info.values()) + + if plugin: + # set its udi + try: + plugin.udi = self._get_modem_path(root_udi) + except RuntimeError, e: + log.err(e, "Error while getting modem path") + plugin.udi = root_udi + + plugin.root_udi = root_udi + # set DBus properties + props = plugin.props[consts.MDM_INTFACE] + props['IpMethod'] = consts.MM_IP_METHOD_PPP + # XXX: Fix CDMA + props['Type'] = consts.MM_MODEM_TYPE_REV['GSM'] + props['Driver'] = self._get_driver_name(root_udi, context) + + if hasattr(plugin, 'preprobe_init'): + # this plugin requires special initialisation before probing + plugin.preprobe_init(ports, extract_info(info)) + + # now get the ports + ports_need_probe = True + if props['Driver'] == 'hso': + hso_props = plugin.props[consts.HSO_INTFACE] + net_device = self._get_network_device(root_udi, context) + hso_props['NetworkDevice'] = net_device + props['IpMethod'] = consts.MM_IP_METHOD_STATIC + dport, cport = self._get_hso_ports(ports) + ports_need_probe = False + + if hasattr(plugin, 'hardcoded_ports'): + # if the device has the hardcoded_ports attribute that means + # that it allocates the data and control port in a funky way + # and thus the indexes are hardcoded. + dport_idx, cport_idx = plugin.hardcoded_ports + dport = ports[dport_idx] + cport = ports[cport_idx] if cport_idx is not None else None + elif ports_need_probe: + # the ports were not hardcoded nor was an HSO device + dport, cport = probe_ports(ports) + + if not dport and not cport: + # this shouldn't happen + msg = 'No data port and no control port with ports: %s' + raise RuntimeError(msg % ports) + + props['Device'] = dport + props['Control'] = cport + + plugin.ports = Ports(dport, cport) + return plugin + + raise RuntimeError("Couldn't find a plugin with info %s" % info) + + +def get_hw_manager(): + try: + return HardwareManager() + except: + return None + + +class LinuxPlugin(UnixPlugin): + """OSPlugin for Linux-based distros""" + + dialer = None + hw_manager = get_hw_manager() + + def __init__(self): + super(LinuxPlugin, self).__init__() + + def is_valid(self): + raise NotImplementedError() + + def add_default_route(self, iface): + """See :meth:`wader.common.interfaces.IOSPlugin.add_default_route`""" + args = ['add', 'default', 'dev', iface] + return utils.getProcessValue('/sbin/route', args, reactor=reactor) + + def delete_default_route(self, iface): + """ + See :meth:`wader.common.interfaces.IOSPlugin.delete_default_route` + """ + args = ['delete', 'default', 'dev', iface] + return utils.getProcessValue('/sbin/route', args, reactor=reactor) + + def add_dns_info(self, (dns1, dns2), iface=None): + """See :meth:`wader.common.interfaces.IOSPlugin.add_dns_info`""" + name = self.__class__.__name__ + log.err(NotImplementedError, + "add_dns_info not implemented in plugin %s" % name) + + def delete_dns_info(self, (dns1, dns2), iface=None): + """See :meth:`wader.common.interfaces.IOSPlugin.delete_dns_info`""" + name = self.__class__.__name__ + log.err(NotImplementedError, + "delete_dns_info not implemented in plugin %s" % name) + + def configure_iface(self, iface, ip='', action='up'): + """See :meth:`wader.common.interfaces.IOSPlugin.configure_iface`""" + assert action in ['up', 'down'] + if action == 'down': + args = [iface, action] + else: + args = [iface, ip, 'netmask', '255.255.255.255', action] + + return utils.getProcessValue('/sbin/ifconfig', args, reactor=reactor) + + def get_iface_stats(self, iface): + """See :meth:`wader.common.interfaces.IOSPlugin.get_iface_stats`""" + stats_path = "/sys/class/net/%s/statistics" % iface + rx_b = join(stats_path, 'rx_bytes') + tx_b = join(stats_path, 'tx_bytes') + try: + return map(int, [get_file_data(rx_b), get_file_data(tx_b)]) + except (IOError, OSError): + return 0, 0 + diff --git a/wader/common/oses/osx.py b/wader/common/oses/osx.py new file mode 100644 index 0000000..300aca9 --- /dev/null +++ b/wader/common/oses/osx.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""DevicePlugin for OSX""" + +import sys + +from zope.interface import implements +from twisted.internet import defer +from twisted.python import log + +from wader.common import consts +from wader.common.hardware.base import _identify_device +from wader.common.interfaces import IHardwareManager +from wader.common.oses.unix import UnixPlugin +from wader.common.plugin import PluginManager +from wader.common.serialport import Ports +from wader.common.startup import setup_and_export_device + +class OSXPlugin(UnixPlugin): + """OSPlugin for OSX""" + + dialer = None + + def __init__(self): + super(OSXPlugin, self).__init__() + self.hw_manager = HardwareManager() + + def get_iface_stats(self, iface): + """See :meth:`wader.common.interfaces.IOSPlugin.get_iface_stats`""" + # TODO: implement + return 0, 0 + + def is_valid(self): + """See :meth:`wader.common.interfaces.IOSPlugin.is_valid`""" + return sys.platform == 'darwin' + + +class HardwareManager(object): + """I find and configure devices""" + implements(IHardwareManager) + + def __init__(self): + super(HardwareManager, self).__init__() + self.controller = None + self.clients = {} + + def register_controller(self, controller): + """ + See :meth:`wader.common.interfaces.IHardwareManager.register_controller` + """ + self.controller = controller + + def get_devices(self): + """See :meth:`wader.common.interfaces.IHardwareManager.get_devices`""" + # so pylint does not complain on Linux + osxserialports = __import__('osxserialports') + devs_info = [d for d in osxserialports.modems() + if 'Modem' in d['suffix']] + deferreds = [] + for dev in devs_info: + port = dev['dialin'] if dev['dialin'] else dev['callout'] + d = defer.maybeDeferred(_identify_device, port) + d.addCallback(self._get_device_from_model, dev) + deferreds.append(d) + + d = defer.gatherResults(deferreds) + d.addCallback(self._check_if_devices_are_registered) + return d + + def _get_device_from_model(self, model, dev_info): + plugin = PluginManager.get_plugin_by_remote_name(model) + if plugin: + props = plugin.props[consts.MDM_INTFACE] + props['Device'] = dev_info['callout'] + props['Control'] = dev_info['dialin'] + # XXX: Fix CDMA + props['Type'] = consts.MM_MODEM_TYPE_REV['GSM'] + plugin.udi = self._get_udi_from_devinfo(dev_info, model) + plugin.ports = Ports(dev_info['callout'], dev_info['dialin']) + + return plugin + + def _check_if_devices_are_registered(self, devices): + for device in devices: + if device.udi not in self.clients: + self._register_client(device, device.udi, True) + + return devices + + def _register_client(self, plugin, udi, emit=False): + """ + Registers `plugin` in `self.clients` using `udi` + + Will emit a DeviceAdded signal if emit is True + """ + log.msg("registering plugin %s using udi %s" % (plugin, udi)) + self.clients[udi] = setup_and_export_device(plugin) + if emit: + self.controller.DeviceAdded(udi) + + def _get_udi_from_devinfo(self, dev_info, model): + base = dev_info['base'].replace('-', '') + udi = "/device/%s/%s" % (base, model.replace(' ', '')) + return udi + diff --git a/wader/common/oses/unix.py b/wader/common/oses/unix.py new file mode 100644 index 0000000..64f17bd --- /dev/null +++ b/wader/common/oses/unix.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""OSPlugin for Unix-based OSes""" + +from wader.common.plugin import OSPlugin +from wader.common.utils import get_file_data + +class UnixPlugin(OSPlugin): + """Plugin for Unix""" + + def __init__(self): + super(UnixPlugin, self).__init__() + + def is_valid(self): + return False + + def get_iface_stats(self, iface): + raise NotImplementedError + + def get_timezone(self): + return get_file_data('/etc/timezone').replace('\n', '') + diff --git a/wader/common/persistent.py b/wader/common/persistent.py new file mode 100644 index 0000000..be51c4a --- /dev/null +++ b/wader/common/persistent.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Data persistance for Wader""" + +from sqlite3 import dbapi2 as sqlite + +import wader.common.consts as consts + +class NetworkOperator(object): + + def __init__(self, netid, country, name, apn, + username, password, dns1, dns2): + self.netid = netid + self.country = country + self.name = name + self.apn = apn + self.username = username + self.password = password + self.dns1 = dns1 + self.dns2 = dns2 + + def __repr__(self): + return "" % self.netid + + def get_args(self): + return (self.netid, self.country, self.name, self.apn, + self.username, self.password, self.dns1, self.dns2) + + +def adapt_netoperator(oper): + return "%s;%s;%s;%s;%s;%s;%s;%s" % oper.get_args() + +def convert_netoperator(s): + return NetworkOperator(*s.split(';')) + +sqlite.register_adapter(NetworkOperator, adapt_netoperator) +sqlite.register_converter("netoperator", convert_netoperator) + + +def get_connection(path): + return sqlite.connect(path, detect_types=sqlite.PARSE_DECLTYPES) + +def populate_networks(network_list, path=consts.NETWORKS_DB): + conn = get_connection(path) + try: + conn.execute("create table networks(n netoperator)") + except sqlite.OperationalError: + return + + cur = conn.cursor() + # some network operators might come with multiple netids, this will + # create a new one for each one of them + for net in network_list: + for netid in net.netid: + oper = NetworkOperator(netid, net.country, net.name, net.apn, + net.username, net.password, net.dns1, net.dns2) + cur.execute("insert into networks values (?)", (oper,)) + conn.commit() + conn.close() + +def get_network_by_id(netid, path=consts.NETWORKS_DB): + netid = str(netid) + conn = get_connection(path) + cur = conn.cursor() + cur.execute("select n from networks") + + ret = None + for oper in cur.fetchall(): + oper = oper[0] + if oper.netid == netid: + ret = oper + break + + conn.close() + return ret + diff --git a/wader/common/plugin.py b/wader/common/plugin.py new file mode 100644 index 0000000..3d47b87 --- /dev/null +++ b/wader/common/plugin.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Plugin system for Wader""" + +from zope.interface import implements +from twisted.python import log +from twisted.plugin import IPlugin, getPlugins + +from wader.common.consts import MDM_INTFACE, HSO_INTFACE +from wader.common.daemon import build_daemon_collection +import wader.common.exceptions as ex +import wader.common.interfaces as interfaces +from wader.common.utils import flatten_list +from wader.common.sim import SIMBaseClass + +class DevicePlugin(object): + """Base class for all plugins""" + + implements(IPlugin, interfaces.IDevicePlugin) + __properties__ = {} + # at what speed should we talk with this device? + baudrate = 115200 + # Class that will initialize the SIM, by default SIMBaseClass + sim_klass = SIMBaseClass + # Response of AT+CGMM + __remote_name__ = "" + # instance of a custom adapter class if device needs customization + custom = None + # instance of the exporter class that will export AT methods + exporter = None + # dialer + dialer = 'default' + + def __init__(self): + super(DevicePlugin, self).__init__() + # sim instance + self.sim = None + # serial connection reference + self.sconn = None + # is this device enabled? + self.enabled = False + # properties for org.freedesktop.DBus.Properties interface + self.props = {} + # collection of daemons + self.daemons = None + # DBus UDI + self.udi = None + self.root_udi = None + # onyl used in devices that like to share ids, like + # huawei's exxx family. It should have at least a + # 'default' key mapping to a safe device that can be + # used to identify the rest of the family + self.mapping = {} + # dictionary with org.freedesktop.DBus.Properties + self.props = { MDM_INTFACE : {}, HSO_INTFACE : {} } + self.ports = None + + def __repr__(self): + args = (self.__class__.__name__, self.ports) + return "<%s %s>" % args + + def close(self, remove_from_conn=False): + """Closes the plugin and frees all the associated resources""" + log.msg("Closing plugin %s" % self) + + if self.sconn and self.sconn.transport: + self.sconn.transport.unregisterProducer() + + try: + if self.ports.cport.obj: + self.ports.cport.obj.connectionLost("Closing connection") + self.ports.cport.obj.loseConnection("Bye!") + self.ports.cport.obj = None + self.ports.cport.path = None + except: + log.err() + + if self.daemons is not None and self.daemons.running: + self.daemons.stop_daemons() + + try: + if self.exporter and remove_from_conn: + self.exporter.remove_from_connection() + except LookupError, e: + log.err(e) + + def initialize(self, arg=None): + """Initializes the SIM""" + def on_init(size): + if not self.daemons: + self.daemons = build_daemon_collection(self) + + self.daemons.start_daemons() + return size + + self.sim = self.sim_klass(self.sconn) + d = self.sim.initialize() + d.addCallback(on_init) + return d + + def patch(self, other): + """Patch myself in-place with the settings of another plugin""" + if not isinstance(other, DevicePlugin): + raise ValueError("Cannot patch myself with a %s" % type(other)) + + self.udi = other.udi + self.root_udi = other.root_udi + self.ports = other.ports + self.props = other.props.copy() + self.baudrate = other.baudrate + + +class RemoteDevicePlugin(DevicePlugin): + """ + Base class from which all the RemoteDevicePlugins should inherit from + """ + implements(IPlugin, interfaces.IRemoteDevicePlugin) + + +BASE_PATH_DICT = {} + +class OSPlugin(object): + """Base class from which all the OSPlugins should inherit from""" + implements(IPlugin, interfaces.IOSPlugin) + dialer = None + hw_manager = None + + def __init__(self): + super(OSPlugin, self).__init__() + + def get_timezone(self): + """ + Returns the timezone + + :rtype: str + """ + raise NotImplementedError() + + def get_tzinfo(self): + """Returns a :class:`pytz.timezone` out the timezone""" + from pytz import timezone + zone = self.get_timezone() + try: + return timezone(zone) + except: + # we're not catching this exception because some dated pytz + # do not include UnknownTimeZoneError, if get_tzinfo doesn't works + # we just return None as its a valid tzinfo and we can't do more + return None + + def get_iface_stats(self, iface): + """ + Returns ``iface`` network statistics + + :rtype: tuple + """ + raise NotImplementedError() + + def is_valid(self): + """Returns True if we are on the given OS/Distro""" + raise NotImplementedError() + + def initialize(self): + """Initializes the plugin""" + pass + + +import wader.plugins +class PluginManager(object): + """I manage WaderCdfL's plugins""" + + @classmethod + def get_plugins(cls, interface=IPlugin, package=wader.plugins): + """ + Returns all the plugins under ``package`` that implement ``interface`` + """ + return getPlugins(interface, package) + + @classmethod + def get_plugin_by_remote_name(cls, name, + interface=interfaces.IDevicePlugin): + """ + Get a plugin by its remote name + + :raise UnknownPluginNameError: When we don't know about the plugin + """ + for plugin in cls.get_plugins(interface, wader.plugins): + if not hasattr(plugin, '__remote_name__'): + continue + + if plugin.__remote_name__ == name: + return plugin + + if hasattr(plugin, 'mapping'): + if name in plugin.mapping: + return plugin.mapping[name]() + + raise ex.UnknownPluginNameError(name) + + @classmethod + def get_plugin_by_vendor_product_id(cls, product_id, vendor_id): + """Get a plugin by its product and vendor ids""" + log.msg("get_plugin_by_id called with 0x%X and 0x%X" % (product_id, + vendor_id)) + for plugin in cls.get_plugins(interfaces.IDevicePlugin): + props = flatten_list(plugin.__properties__.values()) + if int(product_id) in props and int(vendor_id) in props: + if not plugin.mapping: + # regular plugin + return plugin + + # device has multiple personalities... + # this will just return the default plugin for + # the mapping, we keep a reference to the mapping + # once the device is properly identified by + # wader.common.hardware.base::identify_device + _plugin = plugin.mapping['default']() + _plugin.mapping = plugin.mapping + return _plugin + + return None + diff --git a/wader/common/profile.py b/wader/common/profile.py new file mode 100644 index 0000000..4c9aa57 --- /dev/null +++ b/wader/common/profile.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Profile-related classes + +A profile, also known as a connection in NM-lingo, is a group of +settings to be used to dial up. This group of classes should be used +from the user session, that is why they are defined and not instantiated. +""" + +from collections import defaultdict +import os +import time +from uuid import uuid1 +import socket + +import dbus +from dbus.service import BusName, Object, method, signal +import gconf +from twisted.python import log + +from wader.common._dbus import DelayableDBusObject, delayable +from wader.common._gconf import GConfHelper +from wader.common.consts import (NM_USER_SETTINGS, NM_SYSTEM_SETTINGS_OBJ, + NM_SYSTEM_SETTINGS, + NM_SYSTEM_SETTINGS_SECRETS, + NM_SYSTEM_SETTINGS_CONNECTION, + GCONF_PROFILES_BASE, + MM_SYSTEM_SETTINGS_PATH, + WADER_PROFILES_SERVICE, + WADER_PROFILES_INTFACE, + WADER_PROFILES_OBJPATH) +import wader.common.exceptions as ex +from wader.common.secrets import ProfileSecrets +from wader.common.utils import (convert_ip_to_int, patch_list_signature, + convert_int_to_uint) +from wader.common.persistent import get_network_by_id + + +class Profile(GConfHelper, DelayableDBusObject): + """I am a group of settings required to dial up""" + + def __init__(self, opath, gpath, secrets_gpath, props): + self.bus = dbus.SystemBus() + bus_name = BusName(WADER_PROFILES_SERVICE, bus=self.bus) + GConfHelper.__init__(self) + DelayableDBusObject.__init__(self, bus_name, opath) + + self.opath = opath + self.gpath = gpath + self.props = props + + self.secrets = ProfileSecrets(self, secrets_gpath) + + def _write(self, props): + for key, value in props.iteritems(): + new_path = os.path.join(self.gpath, key) + self.set_value(new_path, value) + + self.client.suggest_sync() + + def _load_info(self): + self.props = {} + + if self.client.dir_exists(self.gpath): + self._load_dir(self.gpath, self.props) + + if 'dns' in self.props['ipv4']: + dns = map(convert_int_to_uint, self.props['ipv4']['dns']) + self.props['ipv4']['dns'] = dns + + def _load_dir(self, directory, info): + entries = self.client.all_entries(directory) + for entry in entries: + key = os.path.basename(entry.key) + info[key] = self.get_value(entry.value) + + dirs = self.client.all_dirs(directory) + for _dir in dirs: + dirname = os.path.basename(_dir) + info[dirname] = {} + self._load_dir(_dir, info[dirname]) + + def get_settings(self): + """Returns the profile settings""" + return patch_list_signature(self.props.copy()) + + def get_secrets(self, tag, hints=None, ask=True): + """ + Returns the secrets associated with the profile + + :param tag: The section to use + :param hints: what specific setting are we interested in + :param ask: Should we ask the user if there is no secret? + """ + secrets = self.secrets.get(ask) + if secrets: + return secrets + else: + return {} + + def get_timestamp(self): + """Returns the last time this profile was used""" + try: + return self.get_settings()['connection']['timestamp'] + except KeyError: + return None + + def is_good(self): + """Has this profile been successfully used?""" + return bool(self.get_timestamp()) + + def on_open_keyring(self, tag): + """Callback to be executed when the keyring has been opened""" + secrets = self.secrets.get() + if secrets: + self.GetSecrets.reply(self, result=(secrets,)) + else: + self.KeyNeeded(self, tag) + + def set_secrets(self, tag, secrets): + """ + Sets or updates the secrets associated with the profile + + :param tag: The section to use + :param secrets: The new secret to store + """ + self.secrets.update(secrets) + self.GetSecrets.reply(self, result=(secrets,)) + + def update(self, props): + """Updates the profile with settings ``props``""" + self._write(props) + self._load_info() + + self.Updated(patch_list_signature(self.props)) + + def remove(self): + """Removes the profile""" + self.client.recursive_unset(self.gpath, + gconf.UNSET_INCLUDING_SCHEMA_NAMES) + # emit Removed and unexport from DBus + self.Removed() + self.remove_from_connection() + + @method(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + in_signature='', out_signature='a{sa{sv}}') + def GetSettings(self): + """See :meth:`get_settings`""" + return self.get_settings() + + @signal(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + signature='os') + def KeyNeeded(self, conn_path, tag): + msg = "KeyNeeded emitted for connection: %s tag: %s" + log.msg(msg % (conn_path, tag)) + + @delayable + @method(dbus_interface=NM_SYSTEM_SETTINGS_SECRETS, + in_signature='sasb', out_signature='a{sa{sv}}') + def GetSecrets(self, tag, hints, ask): + def ask_user(): + self.GetSecrets.delay_reply() + self.KeyNeeded(self, tag) + + if ask: + ask_user() + else: + if self.secrets.is_using_keyring(): + secrets = self.get_secrets(tag, hints, ask=False) + else: + secrets = self.get_secrets(tag, hints, ask=True) + + if secrets and tag in secrets: + return secrets + elif self.secrets.is_using_keyring(): + ask_user() + else: + self.secrets.register_open_callback( + lambda : self.on_open_keyring(tag)) + # will emit KeyNeeded if on_open_keyring does not return + # sound secrets + self.GetSecrets.delay_reply() + + @method(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + in_signature='sa{sv}', out_signature='') + def SetSecrets(self, tag, secrets): + """See :meth:`set_secrets`""" + self.set_secrets(tag, secrets) + + @method(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + in_signature='a{sa{sv}}', out_signature='') + def Update(self, options): + """See :meth:`update`""" + self.update(options) + + @method(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + in_signature='', out_signature='') + def Delete(self): + """See :meth:`remove`""" + log.msg("Delete received") + self.remove() + + @signal(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + signature='a{sa{sv}}') + def Updated(self, options): + log.msg("Updated emitted") + + @signal(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION, + signature='') + def Removed(self): + log.msg("Removed emitted") + + +class ProfileManager(Object, GConfHelper): + """I manage profiles in the system""" + + def __init__(self, gpath): + self.bus = dbus.SystemBus() + bus_name = BusName(WADER_PROFILES_SERVICE, bus=self.bus) + Object.__init__(self, bus_name, WADER_PROFILES_OBJPATH) + GConfHelper.__init__(self) + + self.gpath = gpath + self.profiles = {} + self.nm_profiles = {} + self.nm_manager = None + self.index = 0 + + self._init_nm_manager() + + def _init_nm_manager(self): + try: + obj = self.bus.get_object(NM_USER_SETTINGS, NM_SYSTEM_SETTINGS_OBJ) + self.nm_manager = dbus.Interface(obj, NM_SYSTEM_SETTINGS) + except dbus.DBusException, e: + log.err(e, "nm-applet seems to be not around") + # XXX: handle the case where nm-applet is not around + else: + # connect to signals + self._connect_to_signals() + # cache existing profiles + for opath in self.nm_manager.ListConnections(): + self._on_new_nm_profile(opath) + + def _connect_to_signals(self): + self.nm_manager.connect_to_signal("NewConnection", + self._on_new_nm_profile, NM_SYSTEM_SETTINGS) + + def nm_is_available(self): + return self.nm_manager is not None + + def _on_new_nm_profile(self, opath): + obj = self.bus.get_object(NM_USER_SETTINGS, opath) + props = obj.GetSettings(dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION) + if 'gsm' in props: + self._add_nm_profile(obj, props) + + def _add_nm_profile(self, obj, props): + uuid = props['connection']['uuid'] + assert uuid not in self.nm_profiles, "Adding twice the same profile?" + self.nm_profiles[uuid] = obj + + # handle when a NM profile has been externally added + if uuid not in self.profiles: + try: + profile = self._get_profile_from_nm_connection(uuid) + except ex.ProfileNotFoundError: + log.msg("Removing unexisting NM profile %s" % uuid) + del self.nm_profiles[uuid] + else: + self.profiles[uuid] = profile + self.NewConnection(profile.opath) + + def _get_next_dbus_opath(self): + self.index += 1 + return os.path.join(MM_SYSTEM_SETTINGS_PATH, str(self.index)) + + def _get_next_free_gpath(self): + """Returns the next unused slot of /system/networking/connections""" + all_dirs = list(self.client.all_dirs(GCONF_PROFILES_BASE)) + if not all_dirs: + index = 0 + else: + dirs = sorted(map(int, [_dir.split('/')[-1] for _dir in all_dirs])) + index = dirs[-1] + 1 + + return os.path.join(GCONF_PROFILES_BASE, str(index)) + + def _get_profile_from_nm_connection(self, uuid): + for gpath in self.client.all_dirs(GCONF_PROFILES_BASE): + # filter out wlan connections + if self.client.dir_exists(os.path.join(gpath, 'gsm')): + path = os.path.join(gpath, 'connection', 'uuid') + value = self.client.get(path) + if value and uuid == self.get_value(value): + return self._get_profile_from_gconf_path(gpath) + + msg = "NM profile identified by uuid %s could not be found" + raise ex.ProfileNotFoundError(msg % uuid) + + def _get_profile_from_gconf_path(self, gconf_path): + props = defaultdict(dict) + for path in self.client.all_dirs(gconf_path): + for entry in self.client.all_entries(path): + section, key = entry.get_key().split('/')[-2:] + props[section][key] = self.get_value(entry.get_value()) + + return Profile(self._get_next_dbus_opath(), gconf_path, + self.gpath, dict(props)) + + def _do_set_profile(self, path, props): + if not props['ipv4']['ignore-auto-dns']: + props['ipv4']['dns'] = [] + + for key in props: + for name in props[key]: + value = props[key][name] + _path = os.path.join(path, key, name) + + self.set_value(_path, value) + + self.client.suggest_sync() + + def add_profile(self, props): + """Adds a profile with settings ``props``""" + gconf_path = self._get_next_free_gpath() + uuid = props['connection']['uuid'] + + self._do_set_profile(gconf_path, props) + profile = Profile(self._get_next_dbus_opath(), gconf_path, + self.gpath, props) + self.profiles[uuid] = profile + self.NewConnection(profile.opath) + + def get_profile_by_uuid(self, uuid): + """ + Returns the :class:`Profile` identified by ``uuid`` + + :param uuid: The uuid of the profile + :raise ProfileNotFoundError: If no profile was found + """ + if not self.profiles: + # initialise just in case + self.get_profiles() + + try: + return self.profiles[uuid] + except KeyError: + raise ex.ProfileNotFoundError("No profile with uuid %s" % uuid) + + def get_profile_by_object_path(self, opath): + """Returns a :class:`Profile` out of its object path ``opath``""" + for profile in self.profiles.values(): + if profile.opath == opath: + return profile + + raise ex.ProfileNotFoundError("No profile with object path %s" % opath) + + def get_profile_options_from_imsi(self, imsi): + """Generates a new :class:`Profile` from ``imsi``""" + network = get_network_by_id(imsi) + if not network: + raise ex.ProfileNotFoundError("No profile for IMSI %s" % imsi) + + props = {} + + # gsm + props['gsm'] = { 'band' : 0, + 'username' : network.username, + 'password' : network.password, + 'network-type' : 0, + 'number' : '*99#', + 'apn' : network.apn, + 'name' : 'gsm'} + # ppp + props['ppp'] = dict(name='ppp') + # serial + props['serial'] = dict(baud=115200, + name='serial') + # connection + props['connection'] = dict(id=network.name, + autoconnect=False, + timestamp=time.time(), + type='gsm', + name='connection', + uuid=str(uuid1())) + + ignore_auto_dns = True + try: + dns = map(convert_ip_to_int, [network.dns1, network.dns2]) + except (socket.error, TypeError): + # if the DNS are None, this will raise TypeError + ignore_auto_dns = False + dns = [] + + props['ipv4'] = { 'addresses' : [], + 'dns' : dns, + 'ignore-auto-dns': ignore_auto_dns, + 'method' : 'auto', + 'name' : 'ipv4', + 'routes' : [] } + return props + + def get_profiles(self): + """Returns all the profiles in the system""" + if not self.profiles: + for path in self.client.all_dirs(GCONF_PROFILES_BASE): + # filter out wlan connections + if self.client.dir_exists(os.path.join(path, 'gsm')): + profile = self._get_profile_from_gconf_path(path) + uuid = profile.get_settings()['connection']['uuid'] + self.profiles[uuid] = profile + + return self.profiles.values() + + def remove_profile(self, profile): + """Removes profile ``profile``""" + uuid = profile.get_settings()['connection']['uuid'] + assert uuid in self.profiles, "Removing a non-existent profile?" + + self.profiles[uuid].remove() + del self.profiles[uuid] + + # as NetworkManager listens for GConf-DBus signals, we don't need + # to manually sync it + if uuid in self.nm_profiles: + del self.nm_profiles[uuid] + + def update_profile(self, profile, props): + """Updates ``profile`` with settings ``props``""" + uuid = profile.get_settings()['connection']['uuid'] + assert uuid in self.profiles, "Updating a non-existent profile?" + + _profile = self.profiles[uuid] + _profile.update(patch_list_signature(props)) + + if uuid in self.nm_profiles: + obj = self.nm_profiles[uuid] + obj.Update(patch_list_signature(props), + dbus_interface=NM_SYSTEM_SETTINGS_CONNECTION) + + @signal(dbus_interface=WADER_PROFILES_INTFACE, signature='o') + def NewConnection(self, opath): + pass + + @method(dbus_interface=WADER_PROFILES_INTFACE, + in_signature='s', out_signature='o') + def GetNMObjectPath(self, uuid): + """Returns the object path of the connection refered by ``uuid``""" + if uuid not in self.nm_profiles: + raise KeyError("Unknown uuid: %s" % uuid) + + profile = self.nm_profiles[uuid] + return profile.__dbus_object_path__ + diff --git a/wader/common/protocol.py b/wader/common/protocol.py new file mode 100644 index 0000000..67d6a27 --- /dev/null +++ b/wader/common/protocol.py @@ -0,0 +1,783 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Twisted protocols for serial communication""" + +import re + +from twisted.internet import protocol, defer, reactor +from twisted.python.failure import Failure +from twisted.python import log + +import wader.common.aterrors as E +from wader.common.command import ATCmd +import wader.common.signals as S + +# Standard unsolicited notifications +CALL_RECV = re.compile('\r\nRING\r\n') +STK_DEBUG = re.compile('\r\n\+STC:\s\d+\r\n') +# Standard solicited notifications +NEW_SMS = re.compile('\r\n\+CMTI:\s"(?P\w{2})",(?P\d+)\r\n') +SPLIT_PROMPT = re.compile('^\r\n>\s$') +CREG_REGEXP = re.compile('\r\n\+CREG:\s*(?P\d)\r\n') + + +class BufferingStateMachine(object, protocol.Protocol): + """A simple SM that handles low level communication with the device""" + + def __init__(self, device): + super(BufferingStateMachine, self).__init__() + self.device = device + # a reference to the customizer class for notifications + self.custom = device.custom + # current AT command + self.cmd = None + self.state = 'idle' + # idle and wait buffers + self.idlebuf = "" + self.waitbuf = "" + # log prefix for situations where the prefix is not appended + self._prefix = "" + + def _get_log_prefix(self): + if not self._prefix: + if self.device.ports.has_two(): + self._prefix = self.device.ports.cport.obj.logPrefix() + else: + self._prefix = self.device.ports.dport.obj.logPrefix() + + return self._prefix + + def _timeout_eb(self): + """Executed when a command exceeds its timeout""" + msg = "Command '%r' timed out, this is my waitbuf: %s" + e = E.SerialResponseTimeout(msg % (self.cmd, self.waitbuf)) + self.notify_failure(e) + self.transition_to_idle() + + def cancel_current_delayed_call(self): + """ + Cancels current :class:`~wader.common.command.ATCmd` dellayed call + """ + if self.cmd.call_id and self.cmd.call_id.active(): + self.cmd.call_id.cancel() + + def notify_success(self, result): + """ + Notify success to current :class:`~wader.common.command.ATCmd` + """ + self.cancel_current_delayed_call() + try: + self.cmd.deferred.callback(result) + except Exception, e: + args = (self.cmd, result) + log.err(e, "'%r' callback failed with args '%s'" % args) + + def notify_failure(self, failure): + """Notify failure to current :class:`~wader.common.command.ATCmd`""" + self.cancel_current_delayed_call() + self.cmd.deferred.errback(failure) + + def set_cmd(self, cmd): + """ + Sets ``cmd`` as the next command to process + + It also sets an initial timeout and transitions to waiting state + """ + self.cmd = cmd + # set the timeout for this command + self.cmd.call_id = reactor.callLater(cmd.timeout, self._timeout_eb) + self.set_state('waiting') + + def set_state(self, new_state): + """Sets the new state ``new_state``""" + log.msg("state change: %s -> %s" % (self.state, new_state), + system=self._get_log_prefix()) + # the system line got added because no suffix was being added + # to the log in set_state + self.state = new_state + + def transition_to_idle(self): + """Transitions to idle state and cleans internal buffers""" + self.cmd = None + self.set_state('idle') + self.idlebuf = "" + self.waitbuf = "" + + def send_splitcmd(self): + """ + Used to send the second part of a split command after prompt appears + """ + raise NotImplementedError() + + def emit_signal(self, signal, *args, **kwds): + """ + Emits ``signal`` + + :param signal: The name of the signal to emit + :param args: The arguments for the signal ``signal`` + :param kwds: The keywords for the signal ``signal`` + """ + method = getattr(self.device.exporter, signal, None) + if method: + method(*args, **kwds) + else: + log.err("No method registered for signal %s" % signal) + + def dataReceived(self, data): + """See `twisted.internet.protocol.Protocol.dataReceived`""" + state = 'handle_%s' % self.state + getattr(self, state)(data) + + def process_notifications(self, _buffer): + """ + Processes unsolicited notifications in ``_buffer`` + + :param _buffer: Buffer to scan + """ + if not self.device.custom or not self.device.custom.async_regexp: + return _buffer + + custom = self.device.custom + # we have to use re.finditer as some cards like to pipeline + # several asynchronous notifications in one + for match in re.finditer(custom.async_regexp, _buffer): + name, value = match.groups() + if name in custom.signal_translations: + # we obtain the signal name and the associated function + # that will translate the device unsolicited message to + # the signal used in Wader internally + signal, func = custom.signal_translations[name] + + # if we have a transform function defined, then use it + # otherwise use value as args + if func: + try: + args = func(value) + except Exception, e: + msg = "%s can not handle notification %s" + log.err(e, msg % (func, value)) + args = value + + self.emit_signal(signal, args) + + # remove from the idlebuf the match (but only once please) + _buffer = _buffer.replace(match.group(), '', 1) + + return _buffer + + def handle_idle(self, data): + """ + Processes ``data`` in `idle` state + + Being in `idle` state, there are six possible events that must be + handled: + + - STK init garbage + - Call received (we're not handling it in waiting) + - A SMS arrived + - SMS notification (Not handled yet) + - Device's own unsolicited notifications + - Default: i.e. this device originated a notification that we don't + understand yet, the point is to log it and make it visible so the + user can report it to us + """ + log.msg("idle: %r" % data) + self.idlebuf += data + + # most possible event: + # device's own unsolicited notifications + # signal translations stuff + self.idlebuf = self.process_notifications(self.idlebuf) + if not self.idlebuf: + return + + # second most possible event: + # new SMS arrived + match = NEW_SMS.match(self.idlebuf) + if match: + index = int(match.group('id')) + + self.emit_signal(S.SIG_SMS, index) + + self.idlebuf = self.idlebuf.replace(match.group(), '', 1) + if not self.idlebuf: + return + + # third most possible event + match = STK_DEBUG.match(self.idlebuf) + if match: + self.idlebuf = self.idlebuf.replace(match.group(), '') + if not self.idlebuf: + return + + # fourth most possible event + match = CREG_REGEXP.match(self.idlebuf) + if match: + status = int(match.group('status')) + self.emit_signal(S.SIG_CREG, status) + self.idlebuf = self.idlebuf.replace(match.group(), '') + if not self.idlebuf: + return + + # fifth most possible event: + match = CALL_RECV.match(self.idlebuf) + if match: + self.emit_signal(S.SIG_CALL) + + self.idlebuf = self.idlebuf.replace(match.group(), '') + if not self.idlebuf: + return + + log.msg("idle: unmatched data %r" % self.idlebuf) + + def handle_waiting(self, data): + """Process ``data`` in the wait state""" + self.waitbuf += data + self.waitbuf = self.process_notifications(self.waitbuf) + if not self.waitbuf: + return + + try: + cmdinfo = self.custom.cmd_dict[self.cmd.name] + except KeyError, e: + log.err(e, 'command %s not present in my cmd dict' % self.cmd) + return self.transition_to_idle() + + match = cmdinfo['end'].search(self.waitbuf) + if match: # end of response + if cmdinfo['extract']: + # There's an regex to extract info from data + response = list(re.finditer(cmdinfo['extract'], self.waitbuf)) + resp_repr = str([m.groups() for m in response]) + log.msg("%s: callback = %s" % (self.state, resp_repr)) + self.notify_success(response) + + # now clean self.waitbuf + for _m in response: + self.waitbuf = self.waitbuf.replace(_m.group(), '', 1) + # now clean end of command + endmatch = cmdinfo['end'].search(self.waitbuf) + if endmatch: + self.waitbuf = self.waitbuf.replace(endmatch.group(), + '', 1) + else: + # there's no regex in cmdinfo to extract info + log.msg("%s: no callback registered" % self.state) + self.notify_success(self.waitbuf) + self.waitbuf = self.waitbuf.replace(match.group(), '', 1) + + self.transition_to_idle() + else: + # there is no end of response detected, so we have either an error + # or a split command (like send_sms, save_sms, etc.) + match = E.extract_error(self.waitbuf) + if match: + exception, error, m = match + e = exception(error) + log.err(e, "waiting") + # send the failure back + self.notify_failure(Failure(e)) + # remove the exception string from the waitbuf + self.waitbuf = self.waitbuf.replace(m.group(), '', 1) + self.transition_to_idle() + else: + match = SPLIT_PROMPT.match(data) + if match: + log.msg("waiting: split command prompt detected") + self.send_splitcmd() + self.waitbuf = self.waitbuf.replace(match.group(), '', 1) + else: + log.msg("waiting: unmatched data %r" % data) + + + +class SerialProtocol(BufferingStateMachine): + """ + I define the protocol used to communicate with the SIM card + + SerialProtocol communicates with the SIM synchronously, only one command + at a time. However, SerialProtocol offers an asynchronous interface + :meth:`SerialProtocol.queue_at_cmd` which accepts and queues an + :class:`~wader.common.command.ATCmd` and returns a + :class:`~twisted.internet.defer.Deferred` that will be callbacked with + the commands response, or errback if an exception is + raised. + + SerialProtocol actually is an specially tailored Finite State Machine. + After several redesigns and simplifications, this FSM has just two states: + + - idle: sitting idle for user input or an unsolicited response, when a + command is received we send the command and transition to the waiting + state. + - waiting: the FSM is buffering and parsing all the SIM's response to the + command till it matches the regexp that signals the end of the command. + If the command has an associated regexp to extract information, the + buffered response will be parsed and the command's deferred will be + callbacked with the regexp as argument. There are commands that don't + have an associated regexp to extract information as we are not + interested in the "all went ok" response, only if an exception + occurred -e.g. when deleting a contact we are only interested if + something went wront, not if all went ok. + + The transition to each state is driven by regular expressions, each + command has associated a set of regular expressions that make the FSM + change states. This regexps are defined in + :obj:`wader.common.command.CMD_DICT` although the plugin mechanism + offers the possibility of customizing the CMD_DICT through + :class:`~wader.common.hardware.base.Customizer` if a card uses a + different AT string than the rest for that particular command. + """ + def __init__(self, device): + super(SerialProtocol, self).__init__(device) + self.queue = defer.DeferredQueue() + self.mutex = defer.DeferredLock() + self._check_queue() + + def transition_to_idle(self): + """Transitions to idle state and processes next queued `ATCmd`""" + super(SerialProtocol, self).transition_to_idle() + # release the lock and check the queue + self.mutex.release() + self._check_queue() + + def send_splitcmd(self): + """ + Used to send the second part of a split command after prompt appears + """ + self.transport.write(self.cmd.splitcmd) + + def _process_at_cmd(self, cmd): + def _transition_and_send(_): + log.msg("%s: sending %r" % (self.state, cmd.cmd), + system=self._get_log_prefix()) + self.set_cmd(cmd) + self.transport.write(cmd.get_cmd()) + + d = self.mutex.acquire() + d.addCallback(_transition_and_send) + + def _check_queue(self): + # when the next element of the queue is put, _process_at_cmd will be + # callbacked with it + d = self.queue.get() + d.addCallback(self._process_at_cmd) + + def queue_at_cmd(self, cmd): + """ + Queues an :class:`~wader.common.command.ATCmd` ``cmd`` + + This deferred will be callbacked with the command's response + + :rtype: `Deferred` + """ + self.queue.put(cmd) + return cmd.deferred + + +class WCDMAProtocol(SerialProtocol): + """ + A Twisted protocol to chat with WCDMA devices + + I am able to speak with most WCDMA devices, if you want to customize + the command being sent for a particular command, subclass me. + """ + + def __init__(self, device): + super(WCDMAProtocol, self).__init__(device) + + def add_contact(self, name, number, index): + """ + Adds a contact to the SIM card + + :param name: The contact name + :param number: The contact number + :param index: The contact index + """ + category = 145 if number.startswith('+') else 129 + args = (index, number, category, name) + cmd = ATCmd('AT+CPBW=%d,"%s",%d,"%s"' % args, name='add_contact') + return self.queue_at_cmd(cmd) + + def add_sms(self, pdu_len, pdu): + """Returns the index where ``pdu`` was stored""" + atstr = 'AT+CMGW=%d' % pdu_len + cmd = ATCmd(atstr, name='add_sms', eol='\r') + cmd.splitcmd = '%s\x1a' % pdu + return self.queue_at_cmd(cmd) + + def change_pin(self, oldpin, newpin): + """ + Changes ``oldpin`` to ``newpin`` in the SIM card + + :type oldpin: str + :type newpin: str + + :raise GenericError: When the password is incorrect. + :raise IncorrectPassword: When the password is incorrect. + :raise InputValueError: When the PIN != \d{4} + """ + atstr = 'AT+CPWD="SC","%s","%s"' % (str(oldpin), str(newpin)) + cmd = ATCmd(atstr, name='change_pin') + return self.queue_at_cmd(cmd) + + def check_pin(self): + """ + Checks what's necessary to authenticate against the SIM card + + :raise SimBusy: When the SIM is not ready + :raise SimNotStarted: When the SIM is not ready + :raise SimFailure: This exception is raised by Option's colt when + authentication is disabled + :rtype: str + """ + cmd = ATCmd('AT+CPIN?', name='check_pin') + return self.queue_at_cmd(cmd) + + def delete_all_contacts(self): + """Deletes all the contacts in SIM card, function useful for tests""" + d = self.get_used_contact_ids() + def get_contacts_ids_cb(used): + if not used: + return True + + return defer.gatherResults(map(self.delete_contact, used)) + + d.addCallback(get_contacts_ids_cb) + return d + + def delete_all_sms(self): + """Deletes all the messages in SIM card, function useful for tests""" + d = self.get_used_sms_ids() + def delete_all_sms_cb(used): + if not used: + return True + + return defer.gatherResults(map(self.delete_sms, used)) + + d.addCallback(delete_all_sms_cb) + return d + + def delete_contact(self, index): + """Deletes the contact specified by ``index``""" + cmd = ATCmd('AT+CPBW=%d' % index, name='delete_contact') + return self.queue_at_cmd(cmd) + + def delete_sms(self, index): + """Deletes the message specified by ``index``""" + cmd = ATCmd('AT+CMGD=%d' % index, name='delete_sms') + return self.queue_at_cmd(cmd) + + def disable_echo(self): + """Disables echo of AT cmds""" + cmd = ATCmd('ATE0', name='disable_echo') + return self.queue_at_cmd(cmd) + + def enable_echo(self): + """Enables echo of AT cmds""" + cmd = ATCmd('ATE1', name='enable_echo') + return self.queue_at_cmd(cmd) + + def enable_pin(self, pin, enable): + """ + Enables pin authentication at startup + + :type pin: int + :type enable: bool + + :raise GenericError: If ``pin`` is incorrect. + :raise IncorrectPassword: If ``pin`` is incorrect. + :raise ValueError: When ``pin`` != \d{4} + """ + at_str = 'AT+CLCK="SC",%d,"%s"' % (int(enable), str(pin)) + cmd = ATCmd(at_str, name='enable_pin') + return self.queue_at_cmd(cmd) + + def enable_radio(self, enable): + """ + Enables/disable radio stack + """ + cmd = ATCmd("AT+CFUN=%d" % int(enable), name='enable_radio') + cmd.timeout = 30 + return self.queue_at_cmd(cmd) + + def find_contacts(self, pattern): + """Returns a list of contacts that match ``pattern``""" + cmd = ATCmd('AT+CPBF="%s"' % pattern, name='find_contacts') + return self.queue_at_cmd(cmd) + + def get_apns(self): + """Returns all the APNs in the SIM""" + cmd = ATCmd('AT+CGDCONT?', name='get_apns') + return self.queue_at_cmd(cmd) + + def get_card_model(self): + """Returns the SIM card model""" + cmd = ATCmd('AT+CGMM', name='get_card_model') + return self.queue_at_cmd(cmd) + + def get_card_version(self): + """Returns the SIM card version""" + cmd = ATCmd('AT+CGMR', name='get_card_version') + return self.queue_at_cmd(cmd) + + def get_charset(self): + """Returns the current character set name""" + cmd = ATCmd('AT+CSCS?', name='get_charset') + return self.queue_at_cmd(cmd) + + def get_charsets(self): + """Returns the available charsets""" + cmd = ATCmd('AT+CSCS=?', name='get_charsets') + return self.queue_at_cmd(cmd) + + def get_contact_by_index(self, index): + """Returns the contact at ``index``""" + cmd = ATCmd('AT+CPBR=%d' % index, name='get_contact_by_index') + return self.queue_at_cmd(cmd) + + def get_contacts(self): + """ + Returns all the contacts stored in the SIM card + + :raise GenericError: When no contacts are found. + :raise NotFound: When no contacts are found. + :raise SimBusy: When the SIM is not ready. + :raise SimNotStarted: When the SIM is not ready. + + :rtype: list + """ + cmd = ATCmd('AT+CPBR=1,%d' % self.device.sim.size, + name='get_contacts') + return self.queue_at_cmd(cmd) + + def get_imei(self): + """Returns the IMEI number of the SIM card""" + cmd = ATCmd('AT+CGSN', name='get_imei') + return self.queue_at_cmd(cmd) + + def get_imsi(self): + """Returns the IMSI number of the SIM card""" + cmd = ATCmd('AT+CIMI', name='get_imsi') + return self.queue_at_cmd(cmd) + + def get_manufacturer_name(self): + """Returns the manufacturer name of the SIM card""" + cmd = ATCmd('AT+GMI', name='get_manufacturer_name') + return self.queue_at_cmd(cmd) + + def get_netreg_status(self): + """Returns the network registration status""" + cmd = ATCmd('AT+CREG?', name='get_netreg_status') + return self.queue_at_cmd(cmd) + + def get_network_info(self): + """Returns a tuple with the network info""" + cmd = ATCmd('AT+COPS?', name='get_network_info') + return self.queue_at_cmd(cmd) + + def get_network_names(self): + """Returns a tuple with the network info""" + cmd = ATCmd('AT+COPS=?', name='get_network_names') + cmd.timeout = 40 + return self.queue_at_cmd(cmd) + + def get_phonebook_size(self): + """ + Returns the phonebook size of the SIM card + + :raise GenericError: When the SIM is not ready. + :raise SimBusy: When the SIM is not ready. + :raise CMSError500: When the SIM is not ready. + """ + cmd = ATCmd('AT+CPBR=?', name='get_phonebook_size') + cmd.timeout = 15 + return self.queue_at_cmd(cmd) + + def get_pin_status(self): + """Checks whether the pin is enabled or disabled""" + cmd = ATCmd('AT+CLCK="SC",2', name='get_pin_status') + return self.queue_at_cmd(cmd) + + def get_radio_status(self): + """Returns whether the radio is enabled or disabled""" + cmd = ATCmd("AT+CFUN?", name='get_radio_status') + return self.queue_at_cmd(cmd) + + def get_roaming_ids(self): + """Returns a list with the networks we can register with""" + cmd = ATCmd('AT+CPOL?', name='get_roaming_ids') + return self.queue_at_cmd(cmd) + + def get_signal_quality(self): + """Returns a tuple with the RSSI and BER of the connection""" + cmd = ATCmd('AT+CSQ', name='get_signal_quality') + return self.queue_at_cmd(cmd) + + def get_sms(self): + """ + Returns all the messages stored in the SIM card + + :raise GenericError: When no messages are found. + :raise NotFound: When no messages are found. + + :rtype: list + """ + cmd = ATCmd('AT+CMGL=4', name='get_sms') + return self.queue_at_cmd(cmd) + + def get_sms_by_index(self, index): + """Returns the message stored at ``index``""" + cmd = ATCmd('AT+CMGR=%d' % index, name='get_sms_by_index') + return self.queue_at_cmd(cmd) + + def get_sms_format(self): + """Returns the message stored at ``index``""" + cmd = ATCmd('AT+CMGF?', name='get_sms_format') + return self.queue_at_cmd(cmd) + + def get_smsc(self): + """Returns the SMSC stored in the SIM""" + cmd = ATCmd('AT+CSCA?', name='get_smsc') + return self.queue_at_cmd(cmd) + + def get_used_contact_ids(self): + """Returns a list with the used contact ids""" + def errback(failure): + failure.trap(E.NotFound, E.GenericError) + return [] + + d = self.get_contacts() + d.addCallback(lambda contacts: [int(c.group('id')) for c in contacts]) + d.addErrback(errback) + return d + + def get_used_sms_ids(self): + """Returns a list with used SMS ids in the SIM card""" + d = self.get_sms() + def errback(failure): + failure.trap(E.NotFound, E.GenericError) + return [] + + d.addCallback(lambda smslist: [int(s.group('id')) for s in smslist]) + d.addErrback(errback) + return d + + def hso_authenticate(self, context, user, passwd): + """Authenticate using ``user`` and ``passwd``""" + cmd = ATCmd('AT$QCPDPP=%d,1,"%s","%s"' % (context, user, passwd), + name='hso_authenticate') + return self.queue_at_cmd(cmd) + + def hso_get_ip4_config(self): + """Request the IP4 configuration from the device""" + cmd = ATCmd('AT_OWANDATA=1', name='hso_get_ip4_config') + return self.queue_at_cmd(cmd) + + def register_with_netid(self, netid, mode=1, _format=2): + """Registers with ``netid``""" + atstr = 'AT+COPS=%d,%d,"%s"' % (mode, _format, netid) + cmd = ATCmd(atstr, name='register_with_netid') + cmd.timeout = 30 + return self.queue_at_cmd(cmd) + + def reset_settings(self): + """Resets the settings to factory settings""" + cmd = ATCmd('ATZ', name='reset_settings') + return self.queue_at_cmd(cmd) + + def save_sms(self, pdu, pdu_len): + """Returns the index where ``pdu`` was stored""" + cmd = ATCmd('AT+CMGW=%s' % pdu_len, name='save_sms', eol='\r') + cmd.splitcmd = '%s\x1a' % pdu + return self.queue_at_cmd(cmd) + + def send_pin(self, pin): + """ + Authenticates using ``pin`` + + :raise GenericError: Exception raised by Nozomi when PIN is incorrect. + :raise IncorrectPassword: Exception raised when the PIN is incorrect + """ + cmd = ATCmd('AT+CPIN=%s' % str(pin), name='send_pin') + return self.queue_at_cmd(cmd) + + def send_puk(self, puk, pin): + """ + Authenticates using ``puk`` and ``pin`` + + :raise GenericError: Exception raised by Nozomi when PUK is incorrect. + :raise IncorrectPassword: Exception raised when the PUK is incorrect + """ + atstr = 'AT+CPIN="%s","%s"' % (str(puk), str(pin)) + cmd = ATCmd(atstr, name='send_puk') + return self.queue_at_cmd(cmd) + + def send_sms(self, pdu, pdu_len): + """Sends the given pdu and returns the index""" + cmd = ATCmd('AT+CMGS=%d' % pdu_len, name='send_sms', eol='\r') + cmd.splitcmd = '%s\x1a' % pdu + return self.queue_at_cmd(cmd) + + def send_sms_from_storage(self, index): + """Sends the SMS stored at ``index`` and returns the new index""" + cmd = ATCmd('AT+CMSS=%d' % index, name='send_sms_from_storage') + return self.queue_at_cmd(cmd) + + def set_apn(self, index, apn): + """Sets the APN to ``apn`` using ``index``""" + cmd = ATCmd('AT+CGDCONT=%d,"IP","%s"' % (index, apn), name='set_apn') + return self.queue_at_cmd(cmd) + + def set_charset(self, charset): + """Sets the character set used on the SIM""" + cmd = ATCmd('AT+CSCS="%s"' % charset, name='set_charset') + return self.queue_at_cmd(cmd) + + def set_netreg_notification(self, val=1): + """Sets CREG unsolicited notification""" + cmd = ATCmd('AT+CREG=%d' % val, name='set_netreg_notification') + return self.queue_at_cmd(cmd) + + def set_network_info_format(self, mode=0, _format=2): + """Sets the network information format for +COPS queries""" + cmd = ATCmd('AT+COPS=%d,%d' % (mode, _format), + name='set_network_info_format') + return self.queue_at_cmd(cmd) + + def set_sms_format(self, _format=0): + """Sets the format of the SMS""" + cmd = ATCmd('AT+CMGF=%d' % _format, name='set_sms_format') + return self.queue_at_cmd(cmd) + + def set_sms_indication(self, mode=2, mt=1, bm=0, ds=0, bfr=0): + """Sets the SMS indication mode""" + args = 'AT+CNMI=' + ','.join(map(str, [mode, mt, bm, ds, bfr])) + cmd = ATCmd(args, name='set_sms_indication') + return self.queue_at_cmd(cmd) + + def set_smsc(self, number): + """Sets the SMSC""" + cmd = ATCmd('AT+CSCA="%s"' % number, name='set_smsc') + return self.queue_at_cmd(cmd) + + def send_at(self, at_str, name='send_at'): + """Send an arbitrary AT string to the SIM card""" + cmd = ATCmd(at_str, name=name) + return self.queue_at_cmd(cmd) + diff --git a/wader/common/runtime.py b/wader/common/runtime.py new file mode 100644 index 0000000..f82d43f --- /dev/null +++ b/wader/common/runtime.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Some routines to gather information at runtime""" +from os.path import exists + +import dbus + +import wader.common.consts as consts + +try: + obj = dbus.SystemBus().get_object(consts.NM_SERVICE, consts.NM_OBJPATH) + interface = dbus.Interface(obj, consts.NM_INTFACE) + interface.GetDevices() + nm07_present = True +except dbus.DBusException: + nm07_present = False + +# disable NM0.7+ compatibility till they release NM0.8, see +# http://trac.warp.es/wader/ticket/133 +nm07_present = False + +resolvconf_present = exists('/sbin/resolvconf') diff --git a/wader/common/secrets.py b/wader/common/secrets.py new file mode 100644 index 0000000..d1bbdcb --- /dev/null +++ b/wader/common/secrets.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Classes that mediate access to the secrets. This is done through the +:mod:`~wader.common.keyring` module. +""" + +from wader.common import keyring + +class ProfileSecrets(object): + """ + I mediate access to the secrets associated with a profile + + I provide a uniform API to interact with the different keyrings. + """ + def __init__(self, connection, base_gpath): + self.connection = connection + self.uuid = connection.get_settings()['connection']['uuid'] + self.manager = keyring.get_keyring_manager(base_gpath) + self.temporal_secrets = {} + + def get(self, ask=True): + """ + Returns the secrets associated with the profile + + :param ask: Should we ask the user if the keyring is closed? + """ + if self.manager.is_open: + try: + return self.manager.get_secret(self.uuid) + except keyring.KeyringNoMatchError: + # None signals that something went wrong + return None + else: + if self.temporal_secrets: + self.update(self.temporal_secrets, False) + return self.temporal_secrets + else: + return {} + else: + if ask: + self.manager.KeyNeeded(self.connection) + + return self.temporal_secrets + + def update(self, secrets, ask=False): + """ + Updates the secrets associated with the profile + + :param secrets: The new password to use + :param ask: Should we ask the user if the keyring is closed? + """ + _id = self.connection.get_settings()['connection']['id'] + if self.manager.is_open: + self.manager.update_secret(self.uuid, _id, secrets) + else: + if ask: + self.manager.KeyNeeded(self.connection) + self.register_open_callback(lambda: + self.manager.update_secret(self.uuid, _id, + self.temporal_secrets)) + + self.temporal_secrets.update(secrets) + + + def open(self, password): + """Opens the keyring backend using ``password``""" + self.manager.open(password) + + def clean(self): + """Cleans up the profile secrets""" + if self.manager.is_open: + self.manager.delete_secret(self.uuid) + self.manager.write() + + self.temporal_secrets = {} + + def is_using_keyring(self): + return self.manager.is_open + + def register_open_callback(self, callback): + """Registers ``callback`` to be executed when the keyring is open""" + self.manager.register_open_callback(callback) + diff --git a/wader/common/serialport.py b/wader/common/serialport.py new file mode 100644 index 0000000..589e23c --- /dev/null +++ b/wader/common/serialport.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Logging Serial Port and related classes""" + +from twisted.internet.serialport import SerialPort as _SerialPort +from twisted.python import log + +class Port(object): + """I represent a serial port in Wader""" + + def __init__(self, path): + self._port_path = path + self._sport_obj = None + + def get_port_path(self): + return self._port_path + + def set_port_path(self, path): + self._port_path = path + + def get_sport_obj(self): + return self._sport_obj + + def set_sport_obj(self, obj): + self._sport_obj = obj + + path = property(get_port_path, set_port_path) + obj = property(get_sport_obj, set_sport_obj) + + +class Ports(object): + """I am a pair of :class:`~wader.common.serialport.Port` objects""" + def __init__(self, dport, cport): + self.dport = Port(dport) + self.cport = Port(cport) + + def has_two(self): + """ + Check if there are two active ports + + :rtype: bool + """ + return all([self.dport.path, self.cport.path]) + + def __repr__(self): + if not self.cport.path: + return "dport: %s" % (self.dport.path) + + return "dport: %s cport: %s" % (self.dport.path, self.cport.path) + + +class SerialPort(_SerialPort, log.Logger): + """Small wrapper over Twisted's serial port to make it loggable""" + def __init__(self, protocol, port, reactor, baudrate=115200, timeout=.1): + super(SerialPort, self).__init__(protocol, port, reactor, + baudrate=baudrate, timeout=timeout) + self._port = port + + def logPrefix(self): + """Returns the last part of the port being used""" + return self._port.split('/')[-1] + diff --git a/wader/common/shell.py b/wader/common/shell.py new file mode 100644 index 0000000..11554b6 --- /dev/null +++ b/wader/common/shell.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Module to obtain an introspection shell""" + +from twisted.cred import portal, checkers +from twisted.conch import manhole, manhole_ssh + +def get_manhole_factory(namespace, **passwords): + """ + Returns a ``ConchFactory`` instance configured with given settings + + :param namespace: The namespace to use + :param passwords: The passwords to use + :rtype: `twisted.conch.manhole_ssh.ConchFactory` + """ + realm = manhole_ssh.TerminalRealm() + def getManhole(_): + return manhole.Manhole(namespace) + realm.chainedProtocolFactory.protocolFactory = getManhole + p = portal.Portal(realm) + checker = checkers.InMemoryUsernamePasswordDatabaseDontUse(**passwords) + p.registerChecker(checker) + f = manhole_ssh.ConchFactory(p) + return f + diff --git a/wader/common/signals.py b/wader/common/signals.py new file mode 100644 index 0000000..3051efe --- /dev/null +++ b/wader/common/signals.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Wader signals + +Signals used internally in Wader. Some of them implement ModemManager API +""" + +# SIGNALS +SIG_CALL = 'CallReceived' +SIG_CONNECTED = 'Connected' +SIG_CREG = 'CregReceived' +SIG_DEVICE_ADDED = 'DeviceAdded' +SIG_DEVICE_REMOVED = 'DeviceRemoved' +SIG_DISCONNECTED = 'Disconnected' +SIG_INVALID_DNS = 'InvalidDNS' +SIG_NETWORK_MODE = 'NetworkMode' +SIG_RSSI = 'SignalQuality' +SIG_SMS = 'SMSReceived' +SIG_SPEED = 'SpeedChanged' +SIG_TIMEOUT = 'Timeout' + +NO_SIGNAL = 0 +GPRS_SIGNAL = 1 +EDGE_SIGNAL = 2 +UMTS_SIGNAL = 3 +HSDPA_SIGNAL = 4 +TWOG_PREF_SIGNAL = 5 +THREEG_PREF_SIGNAL = 6 +TWOG_ONLY_SIGNAL = 7 +THREEG_ONLY_SIGNAL = 8 +HSUPA_SIGNAL = 9 +HSPA_SIGNAL = 10 + +THREEG_SIGNALS = [UMTS_SIGNAL, HSDPA_SIGNAL, HSUPA_SIGNAL, HSPA_SIGNAL] + diff --git a/wader/common/sim.py b/wader/common/sim.py new file mode 100644 index 0000000..2582790 --- /dev/null +++ b/wader/common/sim.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""SIM startup module""" + +from twisted.python import log +from twisted.internet import defer, reactor + +import wader.common.aterrors as E + +RETRY_ATTEMPTS = 3 +RETRY_TIMEOUT = 3 + +class SIMBaseClass(object): + """ + I take care of initing the SIM + + The actual details of initing the SIM vary from mobile to datacard, so + I am the one to subclass in case your device needs a special startup + """ + def __init__(self, sconn): + super(SIMBaseClass, self).__init__() + self.sconn = sconn + self.size = None + self.charset = 'IRA' + self.num_of_failures = 0 + self.initted = False + + def set_size(self, size): + log.msg("Setting size to %d" % size) + self.size = size + + def set_charset(self, charset): + self.charset = charset + return charset + + def _setup_sms(self): + # Notification when a SMS arrives... + self.sconn.set_sms_indication(2, 1) + # set PDU mode + self.sconn.set_sms_format(0) + + def initialize(self, set_encoding=True): + """ + Initializes the SIM card + + This method sets up encoding, SMS format and notifications + in the SIM. It returns a deferred with the SIM size. + """ + # set up extended error reporting + self.sconn.send_at('AT+CMEE=1') + + if set_encoding: + self._setup_encoding() + + self._setup_sms() + + deferred = defer.Deferred() + + def get_size(auxdef): + d = self.sconn.get_phonebook_size() + def phonebook_size_cb(resp): + self.set_size(resp) + self.initted = True + auxdef.callback(self.size) + + def phonebook_size_eb(failure): + failure.trap(E.GenericError, E.SimBusy, E.SimFailure) + self.num_of_failures += 1 + if self.num_of_failures > RETRY_ATTEMPTS: + # fail gracefully for now, we'll try again + # the next time a contact operation is required + auxdef.callback(None) + return + + reactor.callLater(RETRY_TIMEOUT, get_size, auxdef) + + d.addCallback(phonebook_size_cb) + d.addErrback(phonebook_size_eb) + + return auxdef + + return get_size(deferred) + + def _set_charset(self, charset): + """ + Checks whether is necessary the change and memorizes the used charset + """ + def process_charset(reply): + """ + Only set the new charset if is different from current encoding + """ + if reply != charset: + return self.sconn.set_charset(charset) + + # we already have the wanted UCS2 + self.charset = reply + return defer.succeed(True) + + d = self.sconn.get_charset() + d.addCallback(process_charset) + return d + + def _process_charsets(self, charsets): + for charset in ["UCS2", "IRA", "GSM"]: + if charset in charsets: + return self._set_charset(charset) + + msg = "Couldn't find an appropriated charset in %s" + raise E.CharsetError(msg % charsets) + + def _setup_encoding(self): + d = self.sconn.get_charsets() + d.addCallback(self._process_charsets) + return d diff --git a/wader/common/sms.py b/wader/common/sms.py new file mode 100644 index 0000000..42b67c7 --- /dev/null +++ b/wader/common/sms.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""SMS module for Wader""" + +from datetime import datetime +from time import mktime + +from zope.interface import implements +from messaging import PDU + +from wader.common.interfaces import IMessage + +STO_INBOX, STO_DRAFTS, STO_SENT = 1, 2, 3 + +class Message(object): + """I am a Message in the system""" + implements(IMessage) + + def __init__(self, number, text, index=None, where=None, + csca=None, _datetime=None): + self.number = number + self.text = text + self.index = index + self.where = where + self.csca = csca + self.datetime = _datetime + self.ref = None + self.cnt = None + self.seq = None + + def __repr__(self): + return "" % (self.number, self.text) + + def __eq__(self, m): + if IMessage.providedBy(m): + return self.number == m.number and self.text == m.text + + return False + + def __ne__(self, m): + return not self.__eq__(m) + + +def extract_datetime(datestr): + """ + Returns a ``datetime`` instance out of ``datestr`` + + :param datestr: Date string like YY/MM/DD HH:MM:SS + :rtype: :class:`datetime.datetime` + """ + #datestr comes like "YY/MM/DD HH:MM:SS" + date, time = datestr.split(' ') + year, month, day = map(int, date.split('/')) + if year < 68: + year += 2000 + hour, mins, seconds = map(int, time.split(':')) + + from wader.common.oal import osobj + tz = osobj.get_tzinfo() + + return datetime(year, month, day, hour, mins, seconds, tzinfo=tz) + +def pdu_to_message(pdu): + """ + Converts ``pdu`` to a :class:`~wader.common.sms.Message` object + + :param pdu: The PDU to convert + :rtype: ``Message`` + """ + p = PDU() + sender, datestr, text, csca, ref, cnt, seq = p.decode_pdu(pdu)[:7] + if datestr: + try: + _datetime = extract_datetime(datestr) + except ValueError: + _datetime = datetime.now() + else: + _datetime = None + + m = Message(sender, text, _datetime=_datetime, csca=csca) + m.ref, m.cnt, m.seq = ref, cnt, seq + + return m + +def message_to_pdu(sms, store=False): + """Converts ``sms`` to its PDU representation""" + p = PDU() + csca = "" + if sms.csca: + csca = sms.csca + + return p.encode_pdu(sms.number, sms.text, csca=csca, store=store) + +def sms_to_dict(sms, index=None): + """ + Converts ``sms`` to a dict ready to be sent via DBus + + :param sms: The ``Message`` object to be converted + :rtype: dict + """ + ret = {} + + ret['number'] = sms.number + ret['text'] = sms.text + if sms.where is not None: + ret['where'] = sms.where + if index: + ret['index'] = index + elif sms.index is not None: + ret['index'] = sms.index + if sms.datetime is not None: + ret['timestamp'] = mktime(sms.datetime.timetuple()) + if sms.csca is not None: + ret['smsc'] = sms.csca + + return ret + +def dict_to_sms(d, tz=None): + """ + Converts ``d`` to a :class:`~wader.common.sms.Message` object + + :param d: The dict to be converted + :rtype: ``Message`` + """ + m = Message(d['number'], d['text']) + if 'index' in d: + m.index = d['index'] + if 'where' in d: + m.where = d['where'] + if 'smsc' in d: + m.csca = d['smsc'] + if 'timestamp' in d: + m.datetime = datetime.fromtimestamp(d['timestamp'], tz) + + return m + diff --git a/wader/common/startup.py b/wader/common/startup.py new file mode 100644 index 0000000..6605cc6 --- /dev/null +++ b/wader/common/startup.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Utilities used at startup""" + +import os +import sys + +import dbus +from dbus.mainloop.glib import DBusGMainLoop +from dbus.service import Object, BusName, method, signal +gloop = DBusGMainLoop(set_as_default=True) + +from twisted.application.service import Application, Service +from twisted.internet import reactor, defer +from twisted.python import log + +#from wader.common.config import config +import wader.common.consts as consts +from wader.common._dbus import DBusExporterHelper +from wader.common.persistent import populate_networks +from wader.common.serialport import SerialPort + +DELAY = 10 +ATTACH_DELAY = 1 + +OLDLOCK = os.path.join(consts.DATA_DIR, '.setup-done') + +class WaderService(Service): + """I am a Twisted service that starts up Wader""" + def __init__(self): + self.ctrl = None + self.prof = None + self.dial = None + + def startService(self): + """Starts the Wader service""" + from wader.common.dialer import DialerManager + self.ctrl = StartupController() + self.dial = DialerManager(self.ctrl) + + def get_clients(self): + """ + Helper method for SSH sessions + + :rtype: dict + """ + return self.ctrl.hm.clients + + +class StartupController(Object, DBusExporterHelper): + """ + I manage devices in the system + + Discovery, identification, hotplugging, etc. + + :ivar clients: Dict with a reference to every configured device + """ + def __init__(self): + name = BusName(consts.WADER_SERVICE, + bus=dbus.SystemBus(mainloop=gloop)) + super(StartupController, self).__init__(bus_name=name, + object_path=consts.WADER_OBJPATH) + from wader.common.oal import osobj + self.hm = osobj.hw_manager + assert self.hm is not None, "Running Wader on an unsupported OS?" + self.hm.register_controller(self) + + @method(consts.WADER_INTFACE, in_signature='', out_signature='ao', + async_callbacks=('async_cb', 'async_eb')) + def EnumerateDevices(self, async_cb, async_eb): + """ + Returns a list of object paths with all the found devices + + It also includes the object paths of already handled devices + """ + d = self.hm.get_devices() + d.addCallback(lambda devs: [d.udi for d in devs]) + return self.add_callbacks(d, async_cb, async_eb) + + @signal(consts.WADER_INTFACE, signature='o') + def DeviceAdded(self, udi): + """Emitted when a 3G device is added""" + log.msg("emitting DeviceAdded('%s')" % udi) + + @signal(consts.WADER_INTFACE, signature='o') + def DeviceRemoved(self, udi): + """Emitted when a 3G device is removed""" + log.msg("emitting DeviceRemoved('%s')" % udi) + + +def get_wader_application(): + """ + Returns the application object required by twistd on startup + + In the future this will be the point that will load startup plugins + and modify the application object + """ + service = WaderService() + application = Application(consts.APP_NAME) + + # XXX: restore + #if config.get('plugins/ssh_shell', 'active', False): + # from wader.common import shell + # # user = config.get('plugins', 'ssh_user') not used right now + # passwd = config.get('plugins/ssh_shell', 'ssh_pass', 'admin') + # port = config.get('plugins/ssh_shell', 'ssh_port', + # 'tcp:2222:interface=127.0.0.1') + # factory = shell.get_manhole_factory(dict(service=service), + # #admin=passwd) + # #strports.service(port, factory).setServiceParent(application) + + service.setServiceParent(application) + return application + +def attach_to_serial_port(device): + """Attaches the serial port in ``device``""" + d = defer.Deferred() + ports = device.ports + port = ports.cport if ports.has_two() else ports.dport + port.obj = SerialPort(device.sconn, port.path, reactor, + baudrate=device.baudrate) + reactor.callLater(ATTACH_DELAY, lambda: d.callback(device)) + return d + +def setup_and_export_device(device): + """Sets up ``device`` and exports its methods over DBus""" + if not device.custom.wrapper_klass: + raise AttributeError("No wrapper class for device %s" % device) + + wrapper_klass = device.custom.wrapper_klass + + log.msg("wrapping plugin %s with class %s" % (device, wrapper_klass)) + device.sconn = wrapper_klass(device) + + # Use the exporter that device specifies + if not device.custom.exporter_klass: + raise AttributeError("No exporter class for device %s" % device) + + exporter_klass = device.custom.exporter_klass + + log.msg("exporting %s methods with class %s" % (device, exporter_klass)) + exporter = exporter_klass(device) + device.exporter = exporter + + device.__repr__ = device.__str__ + return device + +def create_skeleton_and_do_initial_setup(): + """I perform the operations needed for the initial user setup""" + if os.path.exists(OLDLOCK): + # old way to signal that the setup is complete + os.unlink(OLDLOCK) + + if os.path.exists(consts.NETWORKS_DB): + # new way to signal that the setup is complete + return + + # regenerate plugin cache + from twisted.plugin import IPlugin, getPlugins + import wader.plugins + list(getPlugins(IPlugin, package=wader.plugins)) + + populate_dbs(populate_networks) + +def populate_dbs(f): + """ + Populates the networks database using ``f`` + + ``f`` is a callable that accepts a list of NetworkOperators and + populates the database + + :type f: callable + """ + try: + # only will succeed on development + networks = __import__('resources/extra/networks') + except ImportError: + try: + # this fails on feisty but not on gutsy + networks = __import__(os.path.join(consts.EXTRA_DIR, 'networks')) + except ImportError: + sys.path.insert(0, consts.EXTRA_DIR) + import networks + + def is_valid(item): + return not item.startswith(("__", "Base", "NetworkOperator")) + + f([getattr(networks, item)() for item in dir(networks) if is_valid(item)]) + diff --git a/wader/common/statem/__init__.py b/wader/common/statem/__init__.py new file mode 100644 index 0000000..f834eef --- /dev/null +++ b/wader/common/statem/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Different state machines used in the app +""" + diff --git a/wader/common/statem/auth.py b/wader/common/statem/auth.py new file mode 100644 index 0000000..887c374 --- /dev/null +++ b/wader/common/statem/auth.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Authentication state machine""" + +from twisted.internet import reactor, defer +from twisted.python import log + +from epsilon.modal import mode, Modal +import wader.common.aterrors as E + +SIM_FAIL_DELAY = 15 +MAX_NUM_SIM_ERRORS = 3 +MAX_NUM_SIM_BUSY = 5 + + +class AuthStateMachine(Modal): + """I authenticate against a device""" + modeAttribute = 'mode' + initialMode = 'get_pin_status' + DELAY = 15 + + def __init__(self, device): + self.device = device + self.deferred = defer.Deferred() + # it will be set to True if AT+CPIN? == +CPIN: READY + self.auth_was_ready = False + self.num_sim_errors = 0 + self.num_sim_busy = 0 + log.msg("starting %s ..." % self.__class__.__name__) + + def __repr__(self): + return "authentication_sm" + + # keyring stuff + def notify_auth_ok(self): + """Called when authentication was successful""" + self.deferred.callback(True) + + def notify_auth_failure(self, failure): + """Called when we faced a failure""" + log.msg("%s: notifying auth failure %s" % (self, failure)) + self.deferred.errback(failure) + + # states callbacks + def check_pin_cb(self, resp): + """Callbacked with check_pin's result""" + self.auth_was_ready = True + self.notify_auth_ok() + + def get_pin_status_cb(self, enabled): + """Callbacked with get_pin_status's result""" + if int(enabled): + self.notify_auth_failure(E.SimPinRequired()) + else: + self.notify_auth_ok() + + def incorrect_pin_eb(self, failure): + """Executed when PIN is incorrect""" + failure.trap(E.IncorrectPassword) + self.notify_auth_failure(failure) + + def incorrect_puk_eb(self, failure): + """Executed when the PUK is incorrect""" + failure.trap(E.IncorrectPassword, E.GenericError) + self.notify_auth_failure(E.IncorrectPassword()) + + def incorrect_puk2_eb(self, failure): + """Executed when the PUK2 is incorrect""" + failure.trap(E.IncorrectPassword, E.GenericError) + self.notify_auth_failure(E.IncorrectPassword()) + + def pin_required_eb(self, failure): + """Executed when SIM PIN is required""" + failure.trap(E.SimPinRequired, E.GenericError) + self.notify_auth_failure(E.SimPinRequired()) + + def puk_required_eb(self, failure): + """Executed when PUK/PUK2 is required""" + failure.trap(E.SimPukRequired, E.SimPuk2Required) + self.notify_auth_failure(failure) + + def sim_failure_eb(self, failure): + """Executed when there's a SIM failure, try again in a while""" + failure.trap(E.SimFailure) + self.num_sim_errors += 1 + if self.num_sim_errors >= MAX_NUM_SIM_ERRORS: + # we can now consider that there's something wrong with the + # device, probably there's no SIM + self.notify_auth_failure(E.SimNotInserted()) + return + + reactor.callLater(SIM_FAIL_DELAY, self.do_next) + + def sim_busy_eb(self, failure): + """Executed when SIM is busy, try again in a while""" + failure.trap(E.SimBusy, E.SimNotStarted, E.GenericError) + self.num_sim_busy += 1 + if self.num_sim_busy >= MAX_NUM_SIM_BUSY: + # we can now consider that there's something wrong with the + # device, probably a firmwarebug + self.notify_auth_failure(E.SimFailure()) + return + + reactor.callLater(SIM_FAIL_DELAY, self.do_next) + + def sim_no_present_eb(self, failure): + """Executed when there's no SIM, errback it""" + failure.trap(E.SimNotInserted) + self.notify_auth_failure(failure) + + # entry point + def start_auth(self): + """ + Starts the authentication + + Returns a deferred that will be callbacked if everything goes alright + + :raise SimFailure: SIM unknown error + :raise SimNotInserted: SIM not inserted + :raise DeviceLockedError: Device is locked + """ + self.do_next() + return self.deferred + + # states + class get_pin_status(mode): + """ + Ask the PIN what's the PIN status + + The SIM can be in one of the following states: + - SIM is ready (already authenticated, or PIN disabled) + - PIN is needed + - PIN2 is needed (not handled) + - PUK/PUK2 is needed + - SIM is not inserted + - SIM's firmware error + """ + def __enter__(self): + pass + def __exit__(self): + pass + + def do_next(self): + log.msg("%s: transition to get_pin_status mode...." % self) + d = self.device.sconn.check_pin() + d.addCallback(self.check_pin_cb) + d.addErrback(self.pin_required_eb) + d.addErrback(self.puk_required_eb) + d.addErrback(self.sim_failure_eb) + d.addErrback(self.sim_busy_eb) + d.addErrback(self.sim_no_present_eb) + + class pin_needed_status(mode): + """ + Three things can happen: + - Auth went OK + - PIN is incorrect + - After three failed PIN auths, PUK is needed + """ + def __enter__(self): + pass + def __exit__(self): + pass + + def do_next(self): + """ + Three things can happen: + - Auth went OK + - PIN is incorrect + - After three failed PIN auths, PUK is needed + """ + pass + + class puk_needed_status(mode): + """ + Three things can happen: + - Auth went OK + - PUK/PIN is incorrect + - After five failed attempts, PUK2 is needed + """ + def __enter__(self): + pass + def __exit__(self): + pass + + def do_next(self): + pass + + class puk2_needed_status(mode): + """ + Three things can happen: + - Auth went OK + - PUK2/PIN is incorrect + - After ten failed attempts, device is locked + """ + def __enter__(self): + pass + def __exit__(self): + pass + + def do_next(self): + pass + diff --git a/wader/common/statem/networkreg.py b/wader/common/statem/networkreg.py new file mode 100644 index 0000000..31a380d --- /dev/null +++ b/wader/common/statem/networkreg.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Network registration state machine""" + +import dbus +from twisted.python import log +from twisted.internet import defer, reactor +from epsilon.modal import mode, Modal + +import wader.common.exceptions as ex +import wader.common.aterrors as E +from wader.common.signals import SIG_CREG +from wader.common.consts import (WADER_SERVICE, STATUS_IDLE, STATUS_HOME, + STATUS_SEARCHING, STATUS_DENIED, + STATUS_UNKNOWN, STATUS_ROAMING) + +REGISTER_TIMEOUT = 15 +MAX_WAIT_TIMES = 6 + +class NetworkRegistrationStateMachine(Modal): + """I am a network registration state machine""" + modeAttribute = 'mode' + initialMode = 'check_registration' + + def __init__(self, sconn, netid=""): + self.sconn = sconn + self.netid = netid + self.deferred = defer.Deferred() + self.call_id = None + # used to track how many times we've been waiting for a new event + self.wait_counter = 0 + self.registering = False + self.tried_manual_registration = False + self.signal_matchs = [] + self.connect_to_signals() + + log.msg("starting %s ..." % self.__class__.__name__) + + def __repr__(self): + return "network_sm" + + def connect_to_signals(self): + bus = dbus.SystemBus() + device = bus.get_object(WADER_SERVICE, self.sconn.device.udi) + sm = device.connect_to_signal(SIG_CREG, self.on_netreg_cb) + self.signal_matchs.append(sm) + + def clean_signals(self): + while self.signal_matchs: + sm = self.signal_matchs.pop() + sm.remove() + + def start_netreg(self): + """ + Starts the network registration process + + Returns a deferred that will be callbacked upon success and + errbacked with a CMEError30 or a NetworkRegistrationError if fails + """ + self.do_next() + return self.deferred + + def notify_success(self, ignored=True): + """Notifies the caller that we have succeed""" + self.deferred.callback(ignored) + self.cancel_counter() + self.clean_signals() + + def notify_failure(self, failure): + """Notifies the caller that we have failed""" + self.deferred.errback(failure) + + self.cancel_counter() + self.clean_signals() + + def cancel_counter(self): + if self.call_id is not None and not self.call_id.called: + self.call_id.cancel() + self.call_id = None + + def restart_counter_or_transition(self, timeout=REGISTER_TIMEOUT): + self.cancel_counter() + self.wait_counter += 1 + if self.wait_counter <= MAX_WAIT_TIMES: + self.call_id = reactor.callLater(timeout, + self.check_if_registered) + elif not self.tried_manual_registration: + self.transitionTo('manual_registration') + self.do_next() + else: + # we have already tried to register manually and it failed + self.notify_failure() + + def register_with_netid(self, netid): + self.tried_manual_registration = True + d = self.sconn.register_with_netid(netid) + d.addCallback(lambda ign: self.check_if_registered()) + + def check_if_registered(self): + d = self.sconn.get_netreg_status() + d.addCallback(self.process_netreg_status) + + def on_netreg_cb(self, status): + """Callback for +CREG notifications""" + # we fake 'mode == 1' as we've already enabled it + self.process_netreg_status((1, status)) + + def process_netreg_status(self, info): + """Processes get_netreg_status callback and reacts accordingly""" + _mode, status = info + + if status == STATUS_IDLE: + # we are not looking for a network + # set up +CREG notification and start network search + if not _mode: + self.sconn.set_netreg_notification(1) + + if not self.registering: + self.sconn.send_at('AT+COPS=0,,') + self.registering = True + + self.restart_counter_or_transition() + + elif status == STATUS_SEARCHING: + # we are looking for a network, give it some time + if not _mode: + self.sconn.set_netreg_notification(1) + + self.restart_counter_or_transition() + + elif status in [STATUS_HOME, STATUS_ROAMING]: + # We have already found our network -unless a netid was + # specified. Lets check if the contraints are satisfied + self.registering = False + self.transitionTo('check_constraints') + self.do_next() + + elif status in [STATUS_DENIED, STATUS_UNKNOWN]: + # Network registration has been either denied or failed + # because of an unspecified reason. + self.registering = False + msg = 'Net registration failed: +CREG: %d,%d' % (_mode, status) + self.notify_failure(ex.NetworkRegistrationError(msg)) + return + + def process_netreg_info(self, info): + """ + Checks if we are registered with the supplied operator (if any) + + It will transition to manual_registration if necessary + """ + status, netid, long_name = info + + if self.netid == netid: + return self.notify_success() + + # turns out we're registered with an operator we shouldn't be + self.transitionTo('manual_registration') + self.do_next() + + def find_netid_to_register_with(self, imsi_prefix): + """ + Registers with the first netid that appears in both +COPS=? and +CPOL? + """ + # we have tried to register with our home network and it has + # failed. We'll try to register with the first netid present + # in AT+COPS=? and AT+CPOL? + def process_netnames(networks): + for n in networks: + if n.netid in [self.netid, imsi_prefix]: + assert self.registering == False, "Registering again?" + self.register_with_netid(n.netid) + self.registering = True + + def process_roaming_ids_cb(roam_operators): + for roam_operator in roam_operators: + if roam_operator in networks: + assert self.registering == False, "Registering again?" + self.register_with_netid(roam_operator.netid) + self.registering = True + break + else: + msg = "Couldnt find a netid in %s and %s to register with" + args = (roam_operators, networks) + raise ex.NetworkRegistrationError(msg % args) + + def process_roaming_ids_eb(failure): + # +CME ERROR 3, +CME ERROR 4 + failure.trap(E.OperationNotAllowed, E.OperationNotSupported) + msg = "Couldnt find a netid in %s to register with" + raise ex.NetworkRegistrationError(msg % networks) + + d = self.sconn.get_roaming_ids() + d.addCallback(process_roaming_ids_cb) + d.addErrback(process_roaming_ids_eb) + + d = self.sconn.get_network_names() + d.addCallback(process_netnames) + + # states + + class check_registration(mode): + """I check +CREG to see whats the initial status""" + def __enter__(self): + log.msg("%s: check_registration entered" % self) + + def __exit__(self): + log.msg("%s: check_registration exited" % self) + + def do_next(self): + d = self.sconn.get_netreg_status() + d.addCallback(self.process_netreg_status) + + + class check_constraints(mode): + """ + We are registered with our home network or roaming + + We are going to check whether it satisfies our constraints or not + """ + def __enter__(self): + log.msg("%s: check_constraints entered" % self) + + def __exit__(self): + log.msg("%s: check_constraints exited" % self) + + def do_next(self): + if not self.netid: + # no netid specified and we're already registered with our + # home network or roaming, this is success + self.notify_success() + return + + d = self.sconn.get_netreg_info() + d.addCallback(self.process_netreg_info) + + + class manual_registration(mode): + """ + I start the manual registration process + + This is due to a +CREG: 1,2 or because the card automatically + registered with an operator and its netid doesn't matches with the + one specified by the user + """ + + def __enter__(self): + log.msg("%s: manual_registration entered" % self) + + def __exit__(self): + log.msg("%s: manual_registration exited" % self) + + def do_next(self): + def process_imsi_cb(imsi): + imsi = imsi[:5] + assert self.registering == False, "Registering again?" + if imsi == self.netid: + # if we've been specified a netid, we cannot do + # much more than trying to register with it and + # if it fails return asap + self.register_with_netid(imsi) + self.registering = True + else: + # look for a netid to register with + self.find_netid_to_register_with(imsi) + + log.msg("%s: obtaining the IMSI..." % self) + d = self.sconn.get_imsi() + d.addCallback(process_imsi_cb) + diff --git a/wader/common/statem/simple.py b/wader/common/statem/simple.py new file mode 100644 index 0000000..1c3bc91 --- /dev/null +++ b/wader/common/statem/simple.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""org.freedesktop.ModemManager.Modem.Simple state machine""" + +from twisted.python import log +from twisted.internet import defer, reactor +from epsilon.modal import mode, Modal + +import wader.common.aterrors as E + + +class SimpleStateMachine(Modal): + """I am a state machine for o.fd.ModemManager.Modem.Simple""" + + modeAttribute = 'mode' + initialMode = 'begin' + + def __init__(self, device, settings): + self.device = device + self.sconn = device.sconn + self.settings = settings + + self.deferred = defer.Deferred() + + def transition_to(self, state): + self.transitionTo(state) + self.do_next() + + def start_simple(self): + """Starts the whole process""" + self.do_next() + return self.deferred + + def notify_success(self, ignored=True): + """Notifies the caller that we have succeed""" + self.deferred.callback(ignored) + + def notify_failure(self, failure): + """Notifies the caller that we have failed""" + self.deferred.errback(failure) + + class begin(mode): + # start by enabling device + def __enter__(self): + log.msg("Simple SM: begin entered") + + def __exit__(self): + log.msg("Simple SM: begin exited") + + def do_next(self): + d = self.sconn.enable_device(True) + d.addCallback(lambda _: self.transition_to('check_pin')) + + class check_pin(mode): + """We are going to check whether auth is ready or not""" + def __enter__(self): + log.msg("Simple SM: check_pin entered") + + def __exit__(self): + log.msg("Simple SM: check_pin exited") + + def do_next(self): + # check the auth state, if its ready go to next state + # otherwise try to auth with the provided pin, give it + # some seconds to settle and go to next state + def check_pin_cb(ignored, wait=False): + if not wait: + self.transition_to('register') + else: + DELAY = self.device.custom.auth_klass.DELAY + reactor.callLater(DELAY, self.transition_to('register')) + + def check_pin_eb_pin_needed(failure): + failure.trap(E.SimPinRequired) + if 'pin' not in self.settings: + self.notify_failure(E.SimPinRequired("No pin provided")) + return + + d = self.sconn.send_pin(self.settings['pin']) + d.addCallback(check_pin_cb, wait=True) + d.addErrback(self.notify_failure) + + d = self.sconn.check_pin() + d.addCallback(check_pin_cb) + d.addErrback(check_pin_eb_pin_needed) + + class register(mode): + """Registers with the given network id""" + def __enter__(self): + log.msg("Simple SM: register entered") + + def __exit__(self): + log.msg("Simple SM: register exited") + + def do_next(self): + if 'network_id' in self.settings: + netid = self.settings['network_id'] + d = self.sconn.register_with_netid(netid) + d.addCallback(lambda _: self.transition_to('set_apn')) + else: + self.transition_to('set_apn') + + class set_apn(mode): + def __enter__(self): + log.msg("Simple SM: set_apn entered") + + def __exit__(self): + log.msg("Simple SM: set_apn exited") + + def do_next(self): + if 'apn' in self.settings: + d = self.sconn.set_apn(self.settings['apn']) + d.addCallback(lambda _: self.transition_to('connect')) + else: + self.transition_to('connect') + + class connect(mode): + def __enter__(self): + log.msg("Simple SM: connect entered") + + def __exit__(self): + log.msg("Simple SM: connect exited") + + def do_next(self): + number = self.settings['number'] + d = self.sconn.connect_to_internet(number) + d.addCallback(lambda _: self.transition_to('done')) + + class done(mode): + def __enter__(self): + log.msg("Simple SM: done entered") + + def __exit__(self): + log.msg("Simple SM: done exited") + + def do_next(self): + self.notify_success() + diff --git a/wader/common/utils.py b/wader/common/utils.py new file mode 100644 index 0000000..8f36f40 --- /dev/null +++ b/wader/common/utils.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.. +"""Misc utilities""" + +from __future__ import with_statement +import re +import socket +import struct +import sys + +from dbus import Array, UInt32 + + +def rssi_to_percentage(rssi): + """ + Converts ``rssi`` to a percentage value + + :rtype: int + """ + return (rssi * 100) / 31 if rssi < 32 else 0 + +def convert_ip_to_int(ip): + """ + Converts ``ip`` to its integer representation + + :param ip: The IP to convert + :type ip: str + :rtype: int + """ + return struct.unpack('i', socket.inet_pton(socket.AF_INET, ip))[0] + +def convert_int_to_ip(i): + """ + Converts ``i`` to its IP representation + + :param i: The integer to convert + :rtype: str + """ + if i > sys.maxint: + i -= 0xffffffff + 1 + return socket.inet_ntop(socket.AF_INET, struct.pack('i', i)) + +def convert_int_to_uint(i): + """ + Converts ``i`` to unsigned int + + Python lacks the unsigned int type, but NetworkManager uses it + all over the place, so we need to support it. + :rtype: unsigned int + """ + if i < 0: + i += 0xffffffff + 1 + return i + +def patch_list_signature(props, signature='au'): + """ + Patches empty list signature in ``props`` with ``signature`` + + :param props: Dictionary with connection options + :type props: dict + :param signature: The signature to use in empty lists + :rtype: dict + """ + for section in props: + for key, val in props[section].iteritems(): + if val == []: + props[section][key] = Array(val, signature=signature) + elif key in ['addresses', 'dns', 'routes']: + value = map(UInt32, map(convert_int_to_uint, val)) + props[section][key] = Array(value, signature='u') + + return props + +def flatten_list(x): + """Flattens ``x`` into a single list""" + result = [] + for el in x: + if hasattr(el, "__iter__") and not isinstance(el, basestring): + result.extend(flatten_list(el)) + else: + result.append(el) + return result + +def revert_dict(d): + """ + Returns a reverted copy of ``d`` + + :rtype: dict + """ + ret = {} + for k, v in d.iteritems(): + ret[v] = k + + return ret + +def natsort(l): + """Naturally sort list ``l`` in place""" + # extracted from http://nedbatchelder.com/blog/200712.html#e20071211T054956 + convert = lambda text: int(text) if text.isdigit() else text + l.sort(key=lambda key: map(convert, re.split('([0-9]+)', key))) + +def get_file_data(path): + """ + Returns the data of the file at ``path`` + + :param path: The file path + :rtype: str + """ + with open(path) as f: + return f.read() + +def save_file(path, data): + """ + Saves ``data`` in ``path`` + + :param path: The file path + :param data: The data to be saved + """ + with open(path, 'w') as f: + f.write(data) + +def is_bogus_ip(ip): + """ + Checks whether ``ip`` is a bogus IP + + :rtype: bool + """ + return ip in ["10.11.12.13", "10.11.12.14"] + +def create_dns_lock(dns1, dns2, path): + """ + Creates a DNS lock for wvdial calls + + :param dns1: Primary nameserver + :param dns2: Secondary nameserver + :param path: Where will be written to + """ + text = """DNS1 %s\nDNS2 %s\n""" % (dns1, dns2) + save_file(path, text) + diff --git a/wader/contrib/__init__.py b/wader/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wader/contrib/aes.py b/wader/contrib/aes.py new file mode 100644 index 0000000..af8c259 --- /dev/null +++ b/wader/contrib/aes.py @@ -0,0 +1,607 @@ +#!/usr/bin/python +# +# aes.py: implements AES - Advanced Encryption Standard +# from the SlowAES project, http://code.google.com/p/slowaes/ +# +# Copyright (c) 2008 Josh Davis ( http://www.josh-davis.org ), +# Alex Martelli ( http://www.aleax.it ) +# +# Ported from C code written by Laurent Haan http://www.progressive-coding.com +# +# Licensed under the Apache License, Version 2.0 +# http://www.apache.org/licenses/ +# +import math + +def append_PKCS7_padding(s): + """return s padded to a multiple of 16-bytes by PKCS7 padding""" + numpads = 16 - (len(s)%16) + return s + numpads*chr(numpads) + +def strip_PKCS7_padding(s): + """return s stripped of PKCS7 padding""" + if len(s)%16 or not s: + raise ValueError("String of len %d can't be PCKS7-padded" % len(s)) + numpads = ord(s[-1]) + if numpads > 16: + raise ValueError("String ending with %r can't be PCKS7-padded" % s[-1]) + return s[:-numpads] + +# constants extracted for a cleaner API +OFB, CFB, CBC = 0, 1, 2 +SIZE_128, SIZE_192, SIZE_256 = 16, 24, 32 + +class AES(object): + # valid key sizes + keySize = dict(SIZE_128=16, SIZE_192=24, SIZE_256=32) + + # Rijndael S-box + sbox = [0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, + 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, + 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, + 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, + 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, + 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, + 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, + 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, + 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, + 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, + 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, + 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, + 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, + 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, + 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, + 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, + 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, + 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, + 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, + 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, + 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, + 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, + 0x54, 0xbb, 0x16] + + # Rijndael Inverted S-box + rsbox = [0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, + 0x9e, 0x81, 0xf3, 0xd7, 0xfb , 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, + 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb , 0x54, + 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, + 0x42, 0xfa, 0xc3, 0x4e , 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, + 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25 , 0x72, 0xf8, + 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, + 0x65, 0xb6, 0x92 , 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, + 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84 , 0x90, 0xd8, 0xab, + 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, + 0x45, 0x06 , 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, + 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b , 0x3a, 0x91, 0x11, 0x41, + 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, + 0x73 , 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, + 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e , 0x47, 0xf1, 0x1a, 0x71, 0x1d, + 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b , + 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, + 0xfe, 0x78, 0xcd, 0x5a, 0xf4 , 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, + 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f , 0x60, + 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, + 0x93, 0xc9, 0x9c, 0xef , 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, + 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61 , 0x17, 0x2b, + 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, + 0x21, 0x0c, 0x7d] + + def getSBoxValue(self, num): + """Retrieves a given S-Box Value""" + return self.sbox[num] + + def getSBoxInvert(self, num): + """Retrieves a given Inverted S-Box Value""" + return self.rsbox[num] + + def rotate(self, word): + """Rijndael's key schedule rotate operation. + + Rotate a word eight bits to the left: eg, rotate(1d2c3a4f) == 2c3a4f1d + Word is an char list of size 4 (32 bits overall). + """ + return word[1:] + word[:1] + + # Rijndael Rcon + Rcon = [0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, + 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, + 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, + 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, + 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, + 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, + 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, + 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, + 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, + 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, + 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, + 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, + 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, + 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, + 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, + 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, + 0xd3, 0xbd, 0x61, 0xc2, 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, + 0x83, 0x1d, 0x3a, 0x74, 0xe8, 0xcb, 0x8d, 0x01, 0x02, 0x04, 0x08, + 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, + 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, + 0xfa, 0xef, 0xc5, 0x91, 0x39, 0x72, 0xe4, 0xd3, 0xbd, 0x61, 0xc2, + 0x9f, 0x25, 0x4a, 0x94, 0x33, 0x66, 0xcc, 0x83, 0x1d, 0x3a, 0x74, + 0xe8, 0xcb ] + + def getRconValue(self, num): + """Retrieves a given Rcon Value""" + return self.Rcon[num] + + def core(self, word, iteration): + """Key schedule core.""" + # rotate the 32-bit word 8 bits to the left + word = self.rotate(word) + # apply S-Box substitution on all 4 parts of the 32-bit word + for i in range(4): + word[i] = self.getSBoxValue(word[i]) + # XOR the output of the rcon operation with i to the first part + # (leftmost) only + word[0] = word[0] ^ self.getRconValue(iteration) + return word + + def expandKey(self, key, size, expandedKeySize): + """Rijndael's key expansion. + + Expands an 128,192,256 key into an 176,208,240 bytes key + + expandedKey is a char list of large enough size, + key is the non-expanded key. + """ + # current expanded keySize, in bytes + currentSize = 0 + rconIteration = 1 + expandedKey = [0] * expandedKeySize + + # set the 16, 24, 32 bytes of the expanded key to the input key + for j in range(size): + expandedKey[j] = key[j] + currentSize += size + + while currentSize < expandedKeySize: + # assign the previous 4 bytes to the temporary value t + t = expandedKey[currentSize-4:currentSize] + + # every 16,24,32 bytes we apply the core schedule to t + # and increment rconIteration afterwards + if currentSize % size == 0: + t = self.core(t, rconIteration) + rconIteration += 1 + # For 256-bit keys, we add an extra sbox to the calculation + if size == SIZE_256 and ((currentSize % size) == 16): + for l in range(4): + t[l] = self.getSBoxValue(t[l]) + + # We XOR t with the four-byte block 16,24,32 bytes before the new + # expanded key. This becomes the next four bytes in the expanded + # key. + for m in range(4): + expandedKey[currentSize] = expandedKey[currentSize - size] ^ \ + t[m] + currentSize += 1 + + return expandedKey + + def addRoundKey(self, state, roundKey): + """Adds (XORs) the round key to the state.""" + for i in range(16): + state[i] ^= roundKey[i] + return state + + def createRoundKey(self, expandedKey, roundKeyPointer): + """Create a round key. + Creates a round key from the given expanded key and the + position within the expanded key. + """ + roundKey = [0] * 16 + for i in range(4): + for j in range(4): + roundKey[j*4+i] = expandedKey[roundKeyPointer + i*4 + j] + return roundKey + + def galois_multiplication(self, a, b): + """Galois multiplication of 8 bit characters a and b.""" + p = 0 + for counter in range(8): + if b & 1: + p ^= a + hi_bit_set = a & 0x80 + a <<= 1 + # keep a 8 bit + a &= 0xFF + if hi_bit_set: + a ^= 0x1b + b >>= 1 + return p + + # + # substitute all the values from the state with the value in the SBox + # using the state value as index for the SBox + # + def subBytes(self, state, isInv): + if isInv: + getter = self.getSBoxInvert + else: + getter = self.getSBoxValue + for i in range(16): + state[i] = getter(state[i]) + return state + + # iterate over the 4 rows and call shiftRow() with that row + def shiftRows(self, state, isInv): + for i in range(4): + state = self.shiftRow(state, i*4, i, isInv) + return state + + # each iteration shifts the row to the left by 1 + def shiftRow(self, state, statePointer, nbr, isInv): + for i in range(nbr): + if isInv: + state[statePointer:statePointer+4] = \ + state[statePointer+3:statePointer+4] + \ + state[statePointer:statePointer+3] + else: + state[statePointer:statePointer+4] = \ + state[statePointer+1:statePointer+4] + \ + state[statePointer:statePointer+1] + return state + + # galois multiplication of the 4x4 matrix + def mixColumns(self, state, isInv): + # iterate over the 4 columns + for i in range(4): + # construct one column by slicing over the 4 rows + column = state[i:i+16:4] + # apply the mixColumn on one column + column = self.mixColumn(column, isInv) + # put the values back into the state + state[i:i+16:4] = column + + return state + + # galois multiplication of 1 column of the 4x4 matrix + def mixColumn(self, column, isInv): + if isInv: + mult = [14, 9, 13, 11] + else: + mult = [2, 1, 1, 3] + cpy = list(column) + g = self.galois_multiplication + + column[0] = g(cpy[0], mult[0]) ^ g(cpy[3], mult[1]) ^ \ + g(cpy[2], mult[2]) ^ g(cpy[1], mult[3]) + column[1] = g(cpy[1], mult[0]) ^ g(cpy[0], mult[1]) ^ \ + g(cpy[3], mult[2]) ^ g(cpy[2], mult[3]) + column[2] = g(cpy[2], mult[0]) ^ g(cpy[1], mult[1]) ^ \ + g(cpy[0], mult[2]) ^ g(cpy[3], mult[3]) + column[3] = g(cpy[3], mult[0]) ^ g(cpy[2], mult[1]) ^ \ + g(cpy[1], mult[2]) ^ g(cpy[0], mult[3]) + return column + + # applies the 4 operations of the forward round in sequence + def aes_round(self, state, roundKey): + state = self.subBytes(state, False) + state = self.shiftRows(state, False) + state = self.mixColumns(state, False) + state = self.addRoundKey(state, roundKey) + return state + + # applies the 4 operations of the inverse round in sequence + def aes_invRound(self, state, roundKey): + state = self.shiftRows(state, True) + state = self.subBytes(state, True) + state = self.addRoundKey(state, roundKey) + state = self.mixColumns(state, True) + return state + + # Perform the initial operations, the standard round, and the final + # operations of the forward aes, creating a round key for each round + def aes_main(self, state, expandedKey, nbrRounds): + state = self.addRoundKey(state, self.createRoundKey(expandedKey, 0)) + i = 1 + while i < nbrRounds: + state = self.aes_round(state, + self.createRoundKey(expandedKey, 16*i)) + i += 1 + state = self.subBytes(state, False) + state = self.shiftRows(state, False) + state = self.addRoundKey(state, + self.createRoundKey(expandedKey, 16*nbrRounds)) + return state + + # Perform the initial operations, the standard round, and the final + # operations of the inverse aes, creating a round key for each round + def aes_invMain(self, state, expandedKey, nbrRounds): + state = self.addRoundKey(state, + self.createRoundKey(expandedKey, 16*nbrRounds)) + i = nbrRounds - 1 + while i > 0: + state = self.aes_invRound(state, + self.createRoundKey(expandedKey, 16*i)) + i -= 1 + state = self.shiftRows(state, True) + state = self.subBytes(state, True) + state = self.addRoundKey(state, self.createRoundKey(expandedKey, 0)) + return state + + # encrypts a 128 bit input block against the given key of size specified + def encrypt(self, iput, key, size): + output = [0] * 16 + # the number of rounds + nbrRounds = 0 + # the 128 bit block to encode + block = [0] * 16 + # set the number of rounds + if size == SIZE_128: + nbrRounds = 10 + elif size == SIZE_192: + nbrRounds = 12 + elif size == SIZE_256: + nbrRounds = 14 + else: + return None + + # the expanded keySize + expandedKeySize = 16*(nbrRounds+1) + + # Set the block values, for the block: + # a0,0 a0,1 a0,2 a0,3 + # a1,0 a1,1 a1,2 a1,3 + # a2,0 a2,1 a2,2 a2,3 + # a3,0 a3,1 a3,2 a3,3 + # the mapping order is a0,0 a1,0 a2,0 a3,0 a0,1 a1,1 ... a2,3 a3,3 + # + # iterate over the columns + for i in range(4): + # iterate over the rows + for j in range(4): + block[(i+(j*4))] = iput[(i*4)+j] + + # expand the key into an 176, 208, 240 bytes key + # the expanded key + expandedKey = self.expandKey(key, size, expandedKeySize) + + # encrypt the block using the expandedKey + block = self.aes_main(block, expandedKey, nbrRounds) + + # unmap the block again into the output + for k in range(4): + # iterate over the rows + for l in range(4): + output[(k*4)+l] = block[(k+(l*4))] + return output + + # decrypts a 128 bit input block against the given key of size specified + def decrypt(self, iput, key, size): + output = [0] * 16 + # the number of rounds + nbrRounds = 0 + # the 128 bit block to decode + block = [0] * 16 + # set the number of rounds + if size == SIZE_128: + nbrRounds = 10 + elif size == SIZE_192: + nbrRounds = 12 + elif size == SIZE_256: + nbrRounds = 14 + else: + return None + + # the expanded keySize + expandedKeySize = 16*(nbrRounds+1) + + # Set the block values, for the block: + # a0,0 a0,1 a0,2 a0,3 + # a1,0 a1,1 a1,2 a1,3 + # a2,0 a2,1 a2,2 a2,3 + # a3,0 a3,1 a3,2 a3,3 + # the mapping order is a0,0 a1,0 a2,0 a3,0 a0,1 a1,1 ... a2,3 a3,3 + + # iterate over the columns + for i in range(4): + # iterate over the rows + for j in range(4): + block[(i+(j*4))] = iput[(i*4)+j] + # expand the key into an 176, 208, 240 bytes key + expandedKey = self.expandKey(key, size, expandedKeySize) + # decrypt the block using the expandedKey + block = self.aes_invMain(block, expandedKey, nbrRounds) + # unmap the block again into the output + for k in range(4): + # iterate over the rows + for l in range(4): + output[(k*4)+l] = block[(k+(l*4))] + return output + + +class AESModeOfOperation(object): + + aes = AES() + + # structure of supported modes of operation + modeOfOperation = dict(OFB=0, CFB=1, CBC=2) + + # converts a 16 character string into a number array + def convertString(self, string, start, end, mode): + if end - start > 16: + end = start + 16 + if mode == CBC: + ar = [0] * 16 + else: ar = [] + + i = start + j = 0 + while len(ar) < end - start: + ar.append(0) + while i < end: + ar[j] = ord(string[i]) + j += 1 + i += 1 + return ar + + # Mode of Operation Encryption + # stringIn - Input String + # mode - mode of type modeOfOperation + # hexKey - a hex key of the bit length size + # size - the bit length of the key + # hexIV - the 128 bit hex Initilization Vector + def encrypt(self, stringIn, mode, key, size, IV): + if len(key) % size: + return None + if len(IV) % 16: + return None + # the AES input/output + plaintext = [] + iput = [0] * 16 + output = [] + ciphertext = [0] * 16 + # the output cipher string + cipherOut = [] + # char firstRound + firstRound = True + if stringIn != None: + for j in range(int(math.ceil(float(len(stringIn))/16))): + start = j*16 + end = j*16+16 + if end > len(stringIn): + end = len(stringIn) + plaintext = self.convertString(stringIn, start, end, mode) + # print 'PT@%s:%s' % (j, plaintext) + if mode == CFB: + if firstRound: + output = self.aes.encrypt(IV, key, size) + firstRound = False + else: + output = self.aes.encrypt(iput, key, size) + for i in range(16): + if len(plaintext)-1 < i: + ciphertext[i] = 0 ^ output[i] + elif len(output)-1 < i: + ciphertext[i] = plaintext[i] ^ 0 + elif len(plaintext)-1 < i and len(output) < i: + ciphertext[i] = 0 ^ 0 + else: + ciphertext[i] = plaintext[i] ^ output[i] + for k in range(end-start): + cipherOut.append(ciphertext[k]) + iput = ciphertext + elif mode == OFB: + if firstRound: + output = self.aes.encrypt(IV, key, size) + firstRound = False + else: + output = self.aes.encrypt(iput, key, size) + for i in range(16): + if len(plaintext)-1 < i: + ciphertext[i] = 0 ^ output[i] + elif len(output)-1 < i: + ciphertext[i] = plaintext[i] ^ 0 + elif len(plaintext)-1 < i and len(output) < i: + ciphertext[i] = 0 ^ 0 + else: + ciphertext[i] = plaintext[i] ^ output[i] + for k in range(end-start): + cipherOut.append(ciphertext[k]) + iput = output + elif mode == CBC: + for i in range(16): + if firstRound: + iput[i] = plaintext[i] ^ IV[i] + else: + iput[i] = plaintext[i] ^ ciphertext[i] + # print 'IP@%s:%s' % (j, iput) + firstRound = False + ciphertext = self.aes.encrypt(iput, key, size) + # always 16 bytes because of the padding for CBC + for k in range(16): + cipherOut.append(ciphertext[k]) + return mode, len(stringIn), cipherOut + + # Mode of Operation Decryption + # cipherIn - Encrypted String + # originalsize - The unencrypted string length - required for CBC + # mode - mode of type modeOfOperation + # key - a number array of the bit length size + # size - the bit length of the key + # IV - the 128 bit number array Initilization Vector + def decrypt(self, cipherIn, originalsize, mode, key, size, IV): + # cipherIn = unescCtrlChars(cipherIn) + if len(key) % size: + return None + if len(IV) % 16: + return None + # the AES input/output + ciphertext = [] + iput = [] + output = [] + plaintext = [0] * 16 + # the output plain text string + stringOut = '' + # char firstRound + firstRound = True + if cipherIn != None: + for j in range(int(math.ceil(float(len(cipherIn))/16))): + start = j*16 + end = j*16+16 + if j*16+16 > len(cipherIn): + end = len(cipherIn) + ciphertext = cipherIn[start:end] + if mode == CFB: + if firstRound: + output = self.aes.encrypt(IV, key, size) + firstRound = False + else: + output = self.aes.encrypt(iput, key, size) + for i in range(16): + if len(output)-1 < i: + plaintext[i] = 0 ^ ciphertext[i] + elif len(ciphertext)-1 < i: + plaintext[i] = output[i] ^ 0 + elif len(output)-1 < i and len(ciphertext) < i: + plaintext[i] = 0 ^ 0 + else: + plaintext[i] = output[i] ^ ciphertext[i] + for k in range(end-start): + stringOut += chr(plaintext[k]) + iput = ciphertext + elif mode == OFB: + if firstRound: + output = self.aes.encrypt(IV, key, size) + firstRound = False + else: + output = self.aes.encrypt(iput, key, size) + for i in range(16): + if len(output)-1 < i: + plaintext[i] = 0 ^ ciphertext[i] + elif len(ciphertext)-1 < i: + plaintext[i] = output[i] ^ 0 + elif len(output)-1 < i and len(ciphertext) < i: + plaintext[i] = 0 ^ 0 + else: + plaintext[i] = output[i] ^ ciphertext[i] + for k in range(end-start): + stringOut += chr(plaintext[k]) + iput = output + elif mode == CBC: + output = self.aes.decrypt(ciphertext, key, size) + for i in range(16): + if firstRound: + plaintext[i] = IV[i] ^ output[i] + else: + plaintext[i] = iput[i] ^ output[i] + firstRound = False + if originalsize < end: + for k in range(originalsize-start): + stringOut += chr(plaintext[k]) + else: + for k in range(end-start): + stringOut += chr(plaintext[k]) + iput = ciphertext + return stringOut + diff --git a/wader/contrib/ifconfig.py b/wader/contrib/ifconfig.py new file mode 100644 index 0000000..ca9f25c --- /dev/null +++ b/wader/contrib/ifconfig.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Author: MonkeeSage at gmail.com +# Modified by Pablo Martí 13/04/2007 +# added support for SIOCGIFDSTADDR +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Python ifconfig + +This is not portable across different OSes +""" + +# seen at [0] with no license, added support for SIOCGIFDSTADDR. I'm +# releasing it under the GPL +# http://mail.python.org/pipermail/python-list/2007-March/429176.html + +import socket, fcntl, struct, platform + +def _ifinfo(sock, addr, ifname): + iface = struct.pack('256s', ifname[:15]) + info = fcntl.ioctl(sock.fileno(), addr, iface) + if addr == 0x8927: + hwaddr = [] + for char in info[18:24]: + hwaddr.append(hex(ord(char))[2:]) + return ':'.join(hwaddr) + else: + return socket.inet_ntoa(info[20:24]) + +def ifconfig(ifname): + ifreq = {'ifname': ifname} + infos = {} + osys = platform.system() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + if osys == 'Linux': + # offsets defined in /usr/include/linux/sockios.h on linux 2.6 + infos['addr'] = 0x8915 # SIOCGIFADDR + infos['brdaddr'] = 0x8919 # SIOCGIFBRDADDR + infos['hwaddr'] = 0x8927 # SIOCSIFHWADDR + infos['netmask'] = 0x891b # SIOCGIFNETMASK + infos['raddr'] = 0x8917 # SIOCGIFDSTADDR + elif 'BSD' in osys: # ??? + infos['addr'] = 0x8915 + infos['brdaddr'] = 0x8919 + infos['hwaddr'] = 0x8927 + infos['netmask'] = 0x891b + infos['raddr'] = 0x8917 + try: + for k, v in infos.items(): + ifreq[k] = _ifinfo(sock, v, ifname) + except: + pass + + sock.close() + return ifreq diff --git a/wader/contrib/tail.py b/wader/contrib/tail.py new file mode 100644 index 0000000..ad87351 --- /dev/null +++ b/wader/contrib/tail.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 1995-2007 Tummy.com, Ltd. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# +# Imported for the wader project on 5 June 2008 by Pablo Martí +# +# Author: Sean Reifschneider +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# This module was fetched from ftp://ftp.tummy.com/pub/tummy/Python/tail.py +# on 15 Jan 2009 by Pablo Marti. Being the TPL a GPL-compatible license, +# I'm redistributing this under the GPLv2. +# +# PabloMarti 15/01/2009: +# PEP-8'ed the module +# get rid of the mainloop and __main__ functions +""" +A module which implements a unix-like "tail" of a file. + +A callback is executed for every new line found in the file. +""" + +import os + +class Tail(object): + """I periodically poll a file and will execute a callback if changed""" + + def __init__(self, filename, callback, tailbytes=0): + """ + Create a Tail object which periodically polls the specified file looking + for new data which was written. The callback routine is called for each + new line found in the file. + + @param filename: File to read. + @param callback: Executed for every line read + @param tailbytes: Specifies bytes from end of file to start reading + """ + super(Tail, self).__init__() + self.skip = tailbytes + self.filename = filename + self.callback = callback + self.fp = None + self.last_size = 0 + self.last_inode = -1 + self.data = '' + + def close(self): + """ + Closes the monitored file and cleans up + """ + self.fp.close() + self.fp = None + self.data = '' + + def process(self): + """ + Examine file looking for new lines. + + When called, this function will process all lines in the file being + tailed, detect the original file being renamed or reopened, etc. + """ + # open file if it's not already open + if not self.fp: + try: + self.fp = open(self.filename, 'r') + stat = os.stat(self.filename) + self.last_inode = stat[1] + + if self.skip >= 0 and stat[6] > self.skip: + self.fp.seek(0 - (self.skip), 2) + + self.skip = -1 + self.last_size = 0 + except (IOError, OSError): + if self.fp: + self.fp.close() + self.skip = -1 # if the file doesn't exist, we don't skip + self.fp = None + + if not self.fp: + return + + # check to see if file has moved under us + try: + stat = os.stat(self.filename) + this_size = stat[6] + this_ino = stat[1] + if this_size < self.last_size or this_ino != self.last_inode: + raise IOError("File has changed") + except OSError: + self.close() + return + + # read if size has changed + if self.last_size < this_size: + while 1: + data = self.fp.read(4096) + if not len(data): + break + + self.data += data + + # process lines within the data + while 1: + pos = self.data.find('\n') + if pos < 0: + break + line = self.data[:pos] + self.data = self.data[pos + 1:] + # line is line read from file + self.callback(line) + + self.last_size = this_size + self.last_inode = this_ino + + return True + diff --git a/wader/plugins/__init__.py b/wader/plugins/__init__.py new file mode 100644 index 0000000..adf3397 --- /dev/null +++ b/wader/plugins/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +I just point to /usr/share/wader-core/plugins +""" + +from wader.common.consts import PLUGINS_DIR + +__path__ = PLUGINS_DIR +__all__ = [] diff --git a/wader/test/__init__.py b/wader/test/__init__.py new file mode 100644 index 0000000..82941fc --- /dev/null +++ b/wader/test/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + diff --git a/wader/test/test_aterrors.py b/wader/test/test_aterrors.py new file mode 100644 index 0000000..c09a47f --- /dev/null +++ b/wader/test/test_aterrors.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the aterrors module""" + +from twisted.trial import unittest +from wader.common.aterrors import extract_error +import wader.common.aterrors as E + +class TestATErrors(unittest.TestCase): + """Tests for wader.common.aterrors""" + + def test_cme_errors_string(self): + raw = '\r\n+CME ERROR: SIM interface not started\r\n' + self.assertEqual(extract_error(raw)[0], E.SimNotStarted) + raw2 = 'AT+CPIN=1330\r\n\r\n+CME ERROR: operation not allowed\r\n' + self.assertEqual(extract_error(raw2)[0], E.OperationNotAllowed) + raw3 = '\r\n+CME ERROR: SIM busy\r\n' + self.assertEqual(extract_error(raw3)[0], E.SimBusy) + + def test_cme_errors_numeric(self): + raw = '\r\n+CME ERROR: 30\r\n' + self.assertEqual(extract_error(raw)[0], E.NoNetwork) + raw = '\r\n+CME ERROR: 100\r\n' + self.assertEqual(extract_error(raw)[0], E.Unknown) + raw = '\r\n+CME ERROR: 14\r\n' + self.assertEqual(extract_error(raw)[0], E.SimBusy) + + def test_cms_errors(self): + raw = '\r\n+CMS ERROR: 500\r\n' + self.assertEqual(extract_error(raw)[0], E.CMSError500) + raw2 = '\r\n+CMS ERROR: 301\r\n' + self.assertEqual(extract_error(raw2)[0], E.CMSError301) + diff --git a/wader/test/test_command.py b/wader/test/test_command.py new file mode 100644 index 0000000..32fe885 --- /dev/null +++ b/wader/test/test_command.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the command module""" + +import re + +from twisted.trial import unittest + +from wader.common.command import get_cmd_dict_copy + +cmd_dict = get_cmd_dict_copy() + +class TestCommandsRegexps(unittest.TestCase): + """Test for the regexps associated with at commands""" + + def test_check_pin_regexp(self): + # [-] SENDING ATCMD 'AT+CPIN?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CPIN: READY\r\n\r\nOK\r\n' + extract = cmd_dict['check_pin']['extract'] + text = '\r\n+CPIN: READY\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('resp'), 'READY') + # [-] WAITING: DATA_RCV = '\r\n+CPIN: SIM PIN\r\n\r\nOK\r\n' + text2 = '\r\n+CPIN: SIM PIN\r\n\r\nOK\r\n' + match = extract.match(text2) + self.failIf(match == None) + self.assertEqual(match.group('resp'), 'SIM PIN') + # [-] WAITING: DATA_RCV = '\r\n+CPIN: SIM PUK2\r\n\r\nOK\r\n' + text3 = '\r\n+CPIN: SIM PUK2\r\n\r\nOK\r\n' + match = extract.match(text3) + self.failIf(match == None) + self.assertEqual(match.group('resp'), 'SIM PUK2') + + def test_find_contacts_regexp(self): + # [-] SENDING ATCMD 'AT+CPBF="0050"\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CPBF: 1,"+23434432",145,"0050006100620065006C0073"\r\n+CPBF: 53,"342239262",129,"005000410043004F0020004D0056002F004D"\r\n+CPBF: 36,"34233231481",129,"005000410043004F002F004D"\r\n+CPBF: 92,"43223963522",129,"0050004100500041002000500041005200540020004D0056002F004D"\r\n+CPBF: 93,"543453302",129,"005000410050004100200054005200420020004D0076002F004D"\r\n+CPBF: 103,"666",129,"005000610073006300750061006C002000560061006C002F004D"\r\n+CPBF: 109,"4534534532070",129,"00500045004F0050004C0045002F004D"\r\n+CPBF: 115,"623434212",129,"005000720069006D006F0020004E00630068006F002F004D"\r\n\r\nOK\r\n' + extract = cmd_dict['find_contacts']['extract'] + text = '\r\n+CPBF: 1,"+23434432",145,"0050006100620065006C0073"\r\n+CPBF: 53,"342239262",129,"005000410043004F0020004D0056002F004D"\r\n+CPBF: 36,"34233231481",129,"005000410043004F002F004D"\r\n+CPBF: 92,"43223963522",129,"0050004100500041002000500041005200540020004D0056002F004D"\r\n+CPBF: 93,"543453302",129,"005000410050004100200054005200420020004D0076002F004D"\r\n+CPBF: 103,"666",129,"005000610073006300750061006C002000560061006C002F004D"\r\n+CPBF: 109,"4534534532070",129,"00500045004F0050004C0045002F004D"\r\n+CPBF: 115,"623434212",129,"005000720069006D006F0020004E00630068006F002F004D"\r\n\r\nOK\r\n' + matches = list(re.finditer(extract, text)) + self.assertEqual(len(matches), 8) + + self.assertEqual(matches[0].group('name'), '0050006100620065006C0073') + self.assertEqual(matches[0].group('number'), '+23434432') + + self.assertEqual(matches[7].group('name'), '005000720069006D006F0020004E00630068006F002F004D') + self.assertEqual(matches[7].group('number'), '623434212') + + def test_get_charsets(self): + extract = cmd_dict['get_charsets']['extract'] + text = '\r\n+CSCS: ("IRA","GSM","UCS2")\r\n' + matches = list(re.finditer(extract, text)) + self.assertEqual(len(matches), 3) + self.assertEqual(matches[0].group('lang'), "IRA") + self.assertEqual(matches[2].group('lang'), "UCS2") + + text2 = '\r\n+CSCS: ("IRA")\r\n' + matches = list(re.finditer(extract, text2)) + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0].group('lang'), "IRA") + + text3 = '\r\n+CSCS: ("IRA","8859-A","UCS2","PCDN")\r\n' + matches = list(re.finditer(extract, text3)) + self.assertEqual(len(matches), 4) + self.assertEqual(matches[0].group('lang'), "IRA") + self.assertEqual(matches[1].group('lang'), "8859-A") + + def test_get_charset_regexp(self): + # [-] SENDING ATCMD 'AT+CSCS?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CSCS: "UCS2"\r\n\r\nOK\r\n' + extract = cmd_dict['get_charset']['extract'] + text = '\r\n+CSCS: "UCS2"\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('lang'), 'UCS2') + + def test_get_card_model_regexp(self): + # [-] SENDING ATCMD 'AT+CGMM\r\n' + # [-] WAITING: DATA_RCV = '\r\nE220\r\n\r\nOK\r\n' + extract = cmd_dict['get_card_model']['extract'] + text = '\r\nE220\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('model'), 'E220') + + def test_get_card_version_regexp(self): + # [-] SENDING ATCMD 'AT+GMR\r\n' + # [-] WAITING: DATA_RCV = '\r\n11.110.01.03.00\r\n\r\nOK\r\n' + extract = cmd_dict['get_card_version']['extract'] + text = '\r\n11.110.01.03.00\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('version'), '11.110.01.03.00') + + def test_get_contacts_regexp(self): + # [-] SENDING ATCMD 'AT+CPBR=1,250\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CPBR: 1,"+23434432",145,"0050006100620065006C0073"\r\n\r\nOK\r\n' + extract = cmd_dict['get_contacts']['extract'] + text = '\r\n+CPBR: 1,"+23434432",145,"0050006100620065006C0073"\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('number'), '+23434432') + self.assertEqual(match.group('name'), '0050006100620065006C0073') + + def test_get_imei_regexp(self): + # [-] SENDING ATCMD 'AT+CGSN\r\n' + # [-] WAITING: DATA_RCV = '\r\n351834012602323\r\n\r\nOK\r\n + extract = cmd_dict['get_imei']['extract'] + text = '\r\n351834012602323\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('imei'), '351834012602323') + + def test_get_imsi_regexp(self): + # [-] SENDING ATCMD 'AT+CIMI\r\n' + # [-] WAITING: DATA_RCV = '\r\n214012001727332\r\n\r\nOK\r\n' + extract = cmd_dict['get_imsi']['extract'] + text = '\r\n214012001727132\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('imsi'), '214012001727132') + + def test_get_netreg_status_regexp(self): + extract = cmd_dict['get_netreg_status']['extract'] + text = '\r\n+CREG: 0,1\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('mode'), '0') + self.assertEqual(match.group('status'), '1') + + def test_get_network_info_regexp(self): + # [-] SENDING ATCMD 'AT+COPS?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+COPS: 0,2,"21401",0\r\n\r\nOK\r\n' + extract = cmd_dict['get_network_info']['extract'] + text = '\r\n+COPS: 0,2,"21401",0\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('netname'), '21401') + self.assertEqual(match.group('status'), '0') + + text2 = '\r\n+COPS: 0,0,"vodafone ES",0\r\n\r\nOK\r\n' + match2 = extract.match(text2) + self.failIf(match2 == None) + self.assertEqual(match2.group('netname'), 'vodafone ES') + self.assertEqual(match2.group('status'), '0') + + def test_get_network_info_regexp_failure(self): + # [-] SENDING ATCMD 'AT+COPS?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+COPS: 0\r\n\r\nOK\r\n' + extract = cmd_dict['get_network_info']['extract'] + text = '\r\n+COPS: 0\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.failIf(int(match.group('error')), 0) + + def test_get_network_info_regexp_ucs2(self): + extract = cmd_dict['get_network_info']['extract'] + text = '\r\n+COPS: 0,0,"0076006f006400610066006f006e0065002000450053",2\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('netname'), '0076006f006400610066006f006e0065002000450053') + self.assertEqual(match.group('status'), '2') + + def test_get_network_names_ucs2_regexp(self): + extract = cmd_dict['get_network_names']['extract'] + text = '\r\n+COPS: (1,"0076006f006400610066006f006e0065002000450053","0076006f00640061002000450053","21401",0),(2,"0076006f0064006100660)\r\n' + matches = list(re.finditer(extract, text)) + self.failIf(matches == None) + self.assertEqual(matches[0].group('lname'), '0076006f006400610066006f006e0065002000450053') + self.assertEqual(matches[0].group('sname'), '0076006f00640061002000450053') + self.assertEqual(matches[0].group('netid'), '21401') + + def test_get_network_names_ovation_regexp(self): + """ + Novatel ovation's output wasnt matched by previous regexp + """ + extract = cmd_dict['get_network_names']['extract'] + text = '\r\n+COPS: (1,"vodafone ES","voda ES","21401",0)\r\n+COPS: (2,"vodafone ES","voda ES","21401",2)\r\n+COPS: (1,"Orange","Orange","21403",2)\r\n+COPS: (1,"Yoigo","YOIGO","21404",2)\r\n+COPS: (1,"Orange","Orange","21403",0)\r\n+COPS: (1,"movistar","movistar","21407",0)\r\n+COPS: (1,"movistar","movistar","21407",2)\r\n\r\nOK\r\n' + matches = list(re.finditer(extract, text)) + self.failIf(matches == None) + self.assertEqual(matches[0].group('lname'), 'vodafone ES') + self.assertEqual(matches[0].group('sname'), 'voda ES') + self.assertEqual(matches[0].group('netid'), '21401') + + def test_get_phonebook_size(self): + # [-] SENDING ATCMD 'AT+CPBR=?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CPBR: (1-250),40,16\r\n\r\nOK\r\n' + extract = cmd_dict['get_phonebook_size']['extract'] + text = '\r\n+CPBR: (1-250),40,16\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('size'), '250') + + def test_get_pin_status(self): + # [-] SENDING ATCMD 'AT+CLCK="SC",2\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CLCK: 1\r\n\r\nOK\r\n' + extract = cmd_dict['get_pin_status']['extract'] + text = '\r\n+CLCK: 1\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('status'), '1') + + + def test_get_roaming_ids_e620(self): + text = '\r\n+CPOL: 1,"20810"\r\n+CPOL: 2,"22210"\r\n+CPOL: 3,"26202"\r\n+CPOL: 4,"26801"\r\n+CPOL: 5,"23415"\r\n+CPOL: 6,"20601"\r\n+CPOL: 7,"22801"\r\n+CPOL: 8,"20404"\r\n+CPOL: 9,"60202"\r\n+CPOL: 10,"27201"\r\n+CPOL: 11,"20205"\r\n+CPOL: 12,"24008"\r\n+CPOL: 13,"22601"\r\n+CPOL: 14,"26001"\r\n+CPOL: 15,"27602"\r\n+CPOL: 16,"65501"\r\n+CPOL: 17,"27801"\r\n+CPOL: 18,"50503"\r\n+CPOL: 19,"63902"\r\n+CPOL: 20,"53001"\r\n+CPOL: 21,"21670"\r\n+CPOL: 22,"44020"\r\n+CPOL: 23,"54201"\r\n+CPOL: 24,"40441"\r\n+CPOL: 25,"23801"\r\n+CPOL: 26,"24405"\r\n+CPOL: 27,"41902"\r\n+CPOL: 28,"24802"\r\n+CPOL: 29,"23201"\r\n+CPOL: 30,"21910"\r\n+CPOL: 31,"29340"\r\n+CPOL: 32,"27402"\r\n+CPOL: 33,"27403"\r\n+CPOL: 34,"24602"\r\n+CPOL: 35,"46000"\r\n+CPOL: 36,"52503"\r\n\r\n\r\nOK\r\n' + extract = re.compile( + """ + \r\n + \+CPOL:\s(?P\d+),"(?P\d+)" + """, re.VERBOSE) + matches = list(re.finditer(extract, text)) + self.assertEqual(len(matches), 36) + self.assertEqual(matches[0].group('netid'), "20810") + + def test_get_signal_quality_regexp(self): + # [-] SENDING ATCMD 'AT+CSQ?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CSQ: 25,99\r\n\r\nOK\r\n' + extract = cmd_dict['get_signal_quality']['extract'] + text = '\r\n+CSQ: 25,99\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('rssi'), '25') + self.assertEqual(match.group('ber'), '99') + + def test_get_smsc_regexp(self): + # [-] SENDING ATCMD 'AT+CSCA?\r\n' + # [-] WAITING: DATA_RCV = '\r\n+CSCA: "002B00330034003600300037003000300033003100310030",145\r\n\r\nOK\r\n' + extract = cmd_dict['get_smsc']['extract'] + text = '\r\n+CSCA: "002B00330034003600300037003000300033003100310030",145\r\n\r\nOK\r\n' + match = extract.match(text) + self.failIf(match == None) + self.assertEqual(match.group('smsc'), '002B00330034003600300037003000300033003100310030') diff --git a/wader/test/test_dbus.py b/wader/test/test_dbus.py new file mode 100644 index 0000000..3f2b555 --- /dev/null +++ b/wader/test/test_dbus.py @@ -0,0 +1,681 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for DBus exported methods""" + +import dbus +import dbus.mainloop.glib +gloop = dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + +from twisted.internet import defer +from twisted.python import log +from twisted.trial import unittest + +from wader.common.config import config +from wader.common.consts import (WADER_SERVICE, CTS_INTFACE, SMS_INTFACE, + NET_INTFACE, CRD_INTFACE) +import wader.common.aterrors as E +from wader.common.startup import (attach_to_serial_port, + setup_and_export_device) +from wader.common.middleware import NetworkOperator +from wader.common.contact import Contact +from wader.common.oal import osobj + + +class TestDBusExportedMethods(unittest.TestCase): + """ + Tests for the exported methods over D-Bus + """ + def setUp(self): + self.sconn = None + self.serial = None + self.device = None + self.remote_obj = None + try: + d = osobj.hw_manager.get_devices() + def get_device_cb(devices): + if not devices: + self.skip = "No devices found" + return + + device = attach_to_serial_port(devices[0]) + self.device = setup_and_export_device(device) + self.sconn = self.device.sconn + d2 = self.device.initialize() + d2.addCallback(self._get_remote_obj) + d2.addCallback(lambda ign: self.sconn.delete_all_sms()) + d2.addCallback(lambda ign: self.sconn.delete_all_contacts()) + return d2 + + d.addCallback(get_device_cb) + return d + except: + log.err() + + def tearDown(self): + return self.device.close() + + def _get_remote_obj(self, ignored=None): + bus = dbus.SystemBus(mainloop=gloop) + try: + self.remote_obj = bus.get_object(WADER_SERVICE, self.device.udi) + self.remote_obj.Enable(dbus_interface=CRD_INTFACE) + except: + log.err() + return True + + def test_AddContact(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Contacts.Add""" + c = Contact("Johnny", "+435443434343") + d = defer.Deferred() + def remote_add_contact_cb(index): + d2 = self.sconn.get_contact_by_index(index) + d2.addCallback(lambda contact: self.assertEqual(contact, c)) + d2.addCallback(lambda _: self.sconn.delete_contact(index)) + d2.addCallback(lambda _: d.callback(True)) + + def remote_add_contact_eb(failure): + log.err(failure) + + self.remote_obj.Add(c.name, c.number, + dbus_interface=CTS_INTFACE, + reply_handler=remote_add_contact_cb, + error_handler=remote_add_contact_eb) + return d + + def test_DeleteContact(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Contacts.Delete""" + def remove_contact(index): + d = defer.Deferred() + def remote_del_contact_cb(): + d2 = self.sconn.get_contact_by_index(index) + d3 = self.failUnlessFailure(d2, E.GenericError) + d3.chainDeferred(d) + + def remote_del_contact_eb(failure): + log.err(failure) + + self.remote_obj.Delete(index, + dbus_interface=CTS_INTFACE, + reply_handler=remote_del_contact_cb, + error_handler=remote_del_contact_eb) + + return d + + c = Contact("OM", "+215443434343") + d = self.sconn.add_contact(c) + d.addCallback(remove_contact) + return d + + def test_DisablePin(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Card.Enable(pin, False) + """ + pin = config.get('test', 'pin', '0000') + d = defer.Deferred() + def remote_disable_pin_cb(): + d2 = self.sconn.get_pin_status() + d2.addCallback(lambda status: self.assertEqual(status, 0)) + d3 = self.sconn.enable_pin(pin) + d3.addCallback(lambda ignored: d.callback(True)) + + def remote_disable_pin_eb(failure): + log.msg("FAILURE RECEIVED %s" % repr(failure)) + log.err(failure) + d.errback(failure) + + self.remote_obj.EnablePin(pin, False, + dbus_interface=CRD_INTFACE, + reply_handler=remote_disable_pin_cb, + error_handler=remote_disable_pin_eb) + return d + + def test_EnablePin(self): + """ + Test that org.freedesktop.ModemManager.Modem.Gsm.Card.Enable + """ + pin = config.get('test', 'pin', '0000') + d = defer.Deferred() + def remote_enable_pin_cb(): + # it seems to have worked + d2 = self.sconn.get_pin_status() + d2.addCallback(lambda status: self.assertEqual(status, 1)) + d2.addCallback(lambda _: d.callback(True)) + + def remote_enable_pin_eb(exception): + log.err(exception) + d.errback(exception) + + self.remote_obj.EnablePin(pin, True, + dbus_interface=CRD_INTFACE, + reply_handler=remote_enable_pin_cb, + error_handler=remote_enable_pin_eb) + return d + + def test_FindContacts(self): + """ + Test that org.freedesktop.ModemManager.Modem.Gsm.Contacts.Find works + """ + contact = Contact("Eugene", "+43534534") + d = self.sconn.add_contact(contact) + def callback(index): + contact.index = index + d2 = defer.Deferred() + def remote_find_contacts_cb(reply): + self.assertEqual(len(reply), 1) + reply = list(reply[0]) + self.assertIn(contact.name, reply) + self.assertIn(contact.number, reply) + self.assertIn(contact.index, reply) + + d3 = self.sconn.delete_contact(contact.index) + d3.addCallback(lambda _: d2.callback(True)) + + def remote_find_contacts_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.Find("Euge", dbus_interface=CTS_INTFACE, + reply_handler=remote_find_contacts_cb, + error_handler=remote_find_contacts_eb) + return d2 + d.addCallback(callback) + return d + + def test_Get_Contact(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Contacts.Get""" + contact = Contact("Mario", "+312232332") + d = self.sconn.add_contact(contact) + def callback(index): + contact.index = index + d2 = defer.Deferred() + def remote_get_contact_by_index_cb(reply): + reply = list(reply) + self.assertIn(contact.name, reply) + self.assertIn(contact.number, reply) + self.assertIn(contact.index, reply) + + d3 = self.sconn.delete_contact(contact.index) + d3.addCallback(lambda _: d2.callback(True)) + + def remote_get_contact_by_index_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.Get(index, + dbus_interface=CTS_INTFACE, + reply_handler=remote_get_contact_by_index_cb, + error_handler=remote_get_contact_by_index_eb) + return d2 + d.addCallback(callback) + return d + + def test_ListContacts(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Contacts.List""" + contact = Contact("Jauma", "+356456445654") + d = self.sconn.add_contact(contact) + def callback(index): + contact.index = index + d2 = defer.Deferred() + def get_contacts_cb(contacts): + def remote_get_contacts_cb(reply): + reply = list(reply[0]) + self.assertIn(contact.name, reply) + self.assertIn(contact.number, reply) + self.assertIn(contact.index, reply) + + d3 = self.sconn.delete_contact(contact.index) + d3.addCallback(lambda _: d2.callback(True)) + + def remote_get_contacts_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.List(dbus_interface=CTS_INTFACE, + reply_handler=remote_get_contacts_cb, + error_handler=remote_get_contacts_eb) + + self.sconn.get_contacts().addCallback(get_contacts_cb) + return d2 + + d.addCallback(callback) + return d + + def test_GetBands(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetBands""" + d = self.sconn.get_bands() + def get_bands_cb(bands): + d2 = defer.Deferred() + def remote_get_bands_cb(reply): + self.assertEqual(reply, bands) + d2.callback(True) + def remote_get_bands_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.GetBands(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_bands_cb, + error_handler=remote_get_bands_eb) + return d2 + + d.addCallback(get_bands_cb) + return d + + def test_GetCardModel(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetModel""" + d = self.sconn.get_card_model() + def callback(model): + d2 = defer.Deferred() + def remote_get_card_model_cb(reply): + self.assertEqual(model, reply) + d2.callback(True) + def remote_get_card_model_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.GetModel(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_card_model_cb, + error_handler=remote_get_card_model_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetCardVersion(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetVersion""" + d = self.sconn.get_card_version() + def callback(version): + d2 = defer.Deferred() + def remote_get_card_version_cb(reply): + self.assertEqual(version, reply) + d2.callback(True) + def remote_get_card_version_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetVersion(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_card_version_cb, + error_handler=remote_get_card_version_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetCharsets(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetCharsets""" + d = self.sconn.get_charsets() + def process_charsets(charsets): + d2 = defer.Deferred() + + def get_charsets_cb(reply): + self.assertEquals(reply, charsets) + d2.callback(True) + + def get_charsets_eb(reply): + log.err(reply) + d2.errback(reply) + + self.remote_obj.GetCharsets(dbus_interface=CRD_INTFACE, + reply_handler=get_charsets_cb, + error_handler=get_charsets_eb) + + return d2 + + d.addCallback(process_charsets) + return d + + def test_GetCharset(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetCharset""" + d = self.sconn.get_charset() + def callback(charset): + d2 = defer.Deferred() + def remote_get_charset_cb(reply): + self.assertEqual(charset, reply) + d2.callback(True) + def remote_get_charset_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetCharset(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_charset_cb, + error_handler=remote_get_charset_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetImei(self): + """Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetImei""" + d = self.sconn.get_imei() + def callback(imei): + d2 = defer.Deferred() + def remote_get_imei_cb(reply): + self.assertEqual(imei, reply) + d2.callback(True) + def remote_get_imei_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetImei(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_imei_cb, + error_handler=remote_get_imei_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetImsi(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetImsi + """ + d = self.sconn.get_imsi() + def callback(imsi): + d2 = defer.Deferred() + def remote_get_imsi_cb(reply): + self.assertEqual(imsi, reply) + d2.callback(True) + def remote_get_imsi_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetImsi(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_imsi_cb, + error_handler=remote_get_imsi_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetManufacturerName(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Card.GetManufacturer + """ + d = self.sconn.get_manufacturer_name() + def callback(name): + d2 = defer.Deferred() + def remote_get_manfname_cb(reply): + self.assertEqual(name, reply) + d2.callback(True) + def remote_get_manfname_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetManufacturer(dbus_interface=CRD_INTFACE, + reply_handler=remote_get_manfname_cb, + error_handler=remote_get_manfname_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetRegistrationInfo(self): + """ + Test org.freedesktop.ModemManager.Gsm.Network.GetRegistrationInfo + """ + d = self.sconn.get_netreg_status() + def callback(status): + d2 = defer.Deferred() + def remote_get_netreg_status_cb(reply): + _status, numeric_oper, long_oper = reply + self.assertEqual(status, _status) + d2.callback(True) + def remote_get_netreg_status_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetRegistrationInfo(dbus_interface=NET_INTFACE, + reply_handler=remote_get_netreg_status_cb, + error_handler=remote_get_netreg_status_eb) + return d2 + + d.addCallback(callback) + return d + + def test_GetNetworkInfo(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Network.GetInfo + """ + d = self.sconn.get_network_info() + def callback(netinfo): + d2 = defer.Deferred() + def remote_get_netinfo_cb(reply): + self.assertEqual(netinfo, reply) + d2.callback(True) + def remote_get_netinfo_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetInfo(dbus_interface=NET_INTFACE, + reply_handler=remote_get_netinfo_cb, + error_handler=remote_get_netinfo_eb) + return d2 + d.addCallback(callback) + return d + + def test_Scan(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Network.Scan + """ + raise unittest.SkipTest("Not ready") + d = self.sconn.get_network_names() + def callback(netobjs): + d2 = defer.Deferred() + def scan_cb(reply): + for struct in reply: + self.assertIn(NetworkOperator(*list(struct)), netobjs) + + d2.callback(True) + + def scan_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.Scan(dbus_interface=NET_INTFACE, + reply_handler=scan_cb, + error_handler=scan_eb) + return d2 + + d.addCallback(callback) + return d + + def test_GetPhonebookSize(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Contacts.GetPhonebookSize + """ + d = self.sconn.get_phonebook_size() + def callback(size): + d2 = defer.Deferred() + def remote_get_phonebooksize_cb(reply): + self.assertEqual(size, reply) + d2.callback(True) + def remote_get_phonebooksize_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetPhonebookSize(dbus_interface=CTS_INTFACE, + reply_handler=remote_get_phonebooksize_cb, + error_handler=remote_get_phonebooksize_eb) + return d2 + + d.addCallback(callback) + return d + + def test_GetRoamingIDs(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Network.GetRoamingIDs + """ + d = self.sconn.get_roaming_ids() + def callback(roaming_ids): + d2 = defer.Deferred() + def remote_get_smsc_cb(reply): + rids = [obj.netid for obj in roaming_ids] + for netid in reply: + self.assertIn(netid, rids) + + d2.callback(True) + def remote_get_smsc_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetRoamingIDs(dbus_interface=NET_INTFACE, + reply_handler=remote_get_smsc_cb, + error_handler=remote_get_smsc_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetSignalQuality(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Network.GetSignalQuality + """ + d = self.sconn.get_signal_quality() + def callback(rids): + d2 = defer.Deferred() + def remote_get_sigqual_cb(reply): + self.assertEqual(rids, reply) + d2.callback(True) + def remote_get_sigqual_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetSignalQuality(dbus_interface=NET_INTFACE, + reply_handler=remote_get_sigqual_cb, + error_handler=remote_get_sigqual_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetSmsc(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.SMS.GetSmsc + """ + d = self.sconn.get_smsc() + def callback(smsc): + d2 = defer.Deferred() + def remote_get_smsc_cb(reply): + self.assertEqual(smsc, reply) + d2.callback(True) + def remote_get_smsc_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetSmsc(dbus_interface=SMS_INTFACE, + reply_handler=remote_get_smsc_cb, + error_handler=remote_get_smsc_eb) + return d2 + d.addCallback(callback) + return d + + def test_GetFormat(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.SMS.GetFormat + """ + d = self.sconn.get_sms_format() + def callback(_format): + d2 = defer.Deferred() + def remote_get_smsc_cb(reply): + self.assertEqual(_format, reply) + d2.callback(True) + def remote_get_smsc_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.GetFormat(dbus_interface=SMS_INTFACE, + reply_handler=remote_get_smsc_cb, + error_handler=remote_get_smsc_eb) + return d2 + d.addCallback(callback) + return d + + def test_SetCharset(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.Card.SetCharset + """ + bad_charset = 'IRA' + old_charset = self.device.sim.charset + d = defer.Deferred() + def remote_set_charset_cb(): + def check_and_set_good_charset_cb(charset): + self.assertEqual(charset, bad_charset) + d2 = self.sconn.set_charset(old_charset) + d2.addCallback(lambda _: d.callback(True)) + return d2 + + # check that the charset we just set over D-Bus is correct + d2 = self.sconn.get_charset() + d2.addCallback(check_and_set_good_charset_cb) + return d2 + + def remote_set_charset_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.SetCharset(bad_charset, + dbus_interface=CRD_INTFACE, + reply_handler=remote_set_charset_cb, + error_handler=remote_set_charset_eb) + return d + + def test_SetFormat(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.SMS.SetFormat + """ + def check_and_set_format(_format): + d2 = defer.Deferred() + def remote_set_format_cb(): + # leave it like we found it + d3 = self.sconn.set_sms_format(0) + d3.chainDeferred(d2) + + def remote_set_format_eb(failure): + log.err(failure) + d2.errback(failure) + + self.assertEquals(_format, 0) + self.remote_obj.SetFormat(1, + dbus_interface=SMS_INTFACE, + reply_handler=remote_set_format_cb, + error_handler=remote_set_format_eb) + + return d2 + + d = self.sconn.get_sms_format() + d.addCallback(check_and_set_format) + return d + + def test_SetSmsc(self): + """ + Test org.freedesktop.ModemManager.Modem.Gsm.SMS.SetSmsc + """ + badsmsc = '+3453456343' + d = defer.Deferred() + def remote_set_smsc_cb(): + def check_and_set_good_smsc_cb(smsc): + self.assertEqual(smsc, badsmsc) + goodsmsc = config.get('test', 'smsc', '+34607003110') + d3 = self.sconn.set_smsc(goodsmsc) + d3.addCallback(lambda ignored: d.callback(True)) + return d3 + + d2 = self.sconn.get_smsc() + d2.addCallback(check_and_set_good_smsc_cb) + return d2 + + def remote_set_smsc_eb(reply): + log.err(reply) + d.errback(reply) + + self.remote_obj.SetSmsc(str(badsmsc), + dbus_interface=SMS_INTFACE, + reply_handler=remote_set_smsc_cb, + error_handler=remote_set_smsc_eb) + return d diff --git a/wader/test/test_dialer.py b/wader/test/test_dialer.py new file mode 100644 index 0000000..f5e280b --- /dev/null +++ b/wader/test/test_dialer.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Authors: Pablo Martí­, Isaac Clerencia +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the dialer module""" + +import re + +import dbus.mainloop.glib +gloop = dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + +from twisted.trial import unittest +from twisted.python import log +from twisted.internet import defer, utils + +from wader.common.oal import osobj +from wader.common.startup import attach_to_serial_port +import wader.common.consts as consts +from wader.contrib import ifconfig + +from wader.gtk.profiles import manager + +class TestDialer(unittest.TestCase): + """Test for dialer module""" + + def setUp(self): + bus = dbus.SystemBus() + self.profile_manager = manager + self.dial_manager = bus.get_object(consts.WADER_DIALUP_SERVICE, + consts.WADER_DIALUP_OBJECT) + self.sconn = None + self.profile = None + self.dial_path = None + try: + d = osobj.hw_manager.get_devices() + def get_device_cb(devices): + self.device = attach_to_serial_port(devices[0]) + self.sconn = self.device.sconn + d2 = self.device.initialize() + d2.addCallback(self.set_profile_from_imsi) + return d2 + + d.addCallback(get_device_cb) + return d + except: + log.err() + + def tearDown(self): + if self.dial_path: + #deactivate connection on teardown if dial_path is set + self.dial_manager.DeactivateConnection(self.dial_path, + dbus_interface=consts.WADER_DIALUP_INTFACE) + + self.profile_manager.remove_profile(self.profile) + + del self.profile_manager + return self.device.close() + + def set_profile_from_imsi(self, ignored): + def set_it(imsi): + imsi = imsi[:5] + log.msg('Setting profile from IMSI') + profile = self.profile_manager.get_profile_options_from_imsi(imsi) + self.profile = profile + return self.profile + + d = self.sconn.get_imsi() + d.addCallback(set_it) + return d + + def test_connection(self): + """ + Checks that connecting with the device works + """ + d = defer.Deferred() + + def _on_connect_cb(dial_path): + log.msg("Dial path: %s" % dial_path) + self.dial_path = dial_path + #check if iface ppp0 is up + # (hardcoded for now, improve wvdial wrapper to detect this) + iface = ifconfig.ifconfig('ppp0') + try: + self.assertTrue(iface.has_key('raddr')) + except: + d.errback() + + def _process_ping_output(output): + m = re.search('([0-9]+) packets transmitted, ' + '([0-9]+) received', output) + try: + self.assertTrue(m) + except: + d.errback() + + tx = int(m.group(1)) + rx = int(m.group(2)) + log.msg('%d packets tx, %d packets rx' % (tx, rx)) + try: + self.assertTrue(rx > 0) + except: + d.errback() + + def _deactivate_connection(ignored): + log.msg("Trying to disconnect") + self.dial_manager.DeactivateConnection(self.dial_path, + dbus_interface=consts.WADER_DIALUP_INTFACE, + reply_handler=_on_disconnect_cb, + error_handler=_on_disconnect_eb) + self.dial_path = None + + d2 = utils.getProcessOutput('ping', + ('-c', '5', '-W', '3', 'www.google.com')) + d2.addCallback(_process_ping_output) + d2.addCallback(_deactivate_connection) + d2.addErrback(lambda ign: d.errback()) + + def _on_disconnect_cb(): + #check if iface ppp0 is down + # (hardcoded for now, improve wvdial wrapper to detect this) + iface = ifconfig.ifconfig('ppp0') + try: + self.assertTrue(not iface.has_key('raddr')) + except: + d.errback() + + log.msg("Disconnected correctly") + + d.callback(True) + + def _on_disconnect_eb(): + d.errback() + + log.msg("Profile path: %s" % self.profile) + log.msg("Device path: %s" % self.device.udi) + self.dial_manager.ActivateConnection(self.profile, self.device.udi, + dbus_interface=consts.WADER_DIALUP_INTFACE, + reply_handler=_on_connect_cb, + error_handler=lambda ign: + self.assertTrue(False)) + + return d + diff --git a/wader/test/test_encoding.py b/wader/test/test_encoding.py new file mode 100644 index 0000000..971afff --- /dev/null +++ b/wader/test/test_encoding.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the encoding module""" + +from twisted.trial import unittest + +from wader.common.encoding import (check_if_ucs2, + pack_ucs2_bytes, unpack_ucs2_bytes) + +class TestEncoding(unittest.TestCase): + """Tests for encoding""" + + def test_check_if_ucs2(self): + self.assertEqual(check_if_ucs2('00'), False) + self.assertEqual(check_if_ucs2('mañico'), False) + self.assertEqual(check_if_ucs2('0056006F006400610066006F006E0065'), + True) + self.assertEqual(check_if_ucs2('1234'), False) + + def test_pack_ucs2_bytes(self): + # 07911356131313F311000A9260214365870008AA080068006F006C0061 + self.assertEqual(pack_ucs2_bytes('hola'), '0068006F006C0061') + # 07911356131313F311000A9260214365870008AA0A0068006F006C00610073 + self.assertEqual(pack_ucs2_bytes('holas'), '0068006F006C00610073') + + def test_unpack_ucs2_bytes(self): + self.assertEqual(unpack_ucs2_bytes('0068006F006C0061'), 'hola') + resp = 'holas' + self.assertEqual(unpack_ucs2_bytes('0068006F006C00610073'), resp) + diff --git a/wader/test/test_netspeed.py b/wader/test/test_netspeed.py new file mode 100644 index 0000000..95ac2dc --- /dev/null +++ b/wader/test/test_netspeed.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for wader.common.netspeed""" + +from twisted.trial import unittest + +from wader.common.netspeed import bps_to_human + +class TestNetSpeed(unittest.TestCase): + """Tests for wader.common.netspeed""" + + def test_bps_to_human(self): + self.assertEqual(bps_to_human(1001, 1000), ('1.00 Kbps', '1.00 Kbps')) + self.assertEqual(bps_to_human(1000001, 1000001), + ('1.00 Mbps', '1.00 Mbps')) + self.assertEqual(bps_to_human(100, 100), ('100.00 bps', '100.00 bps')) + diff --git a/wader/test/test_persistent.py b/wader/test/test_persistent.py new file mode 100644 index 0000000..adc339e --- /dev/null +++ b/wader/test/test_persistent.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the persistent module""" + +import os + +from twisted.trial import unittest + +from wader.common.persistent import populate_networks, get_network_by_id + +TMPFILE = '/tmp/foo.db' + + +class TestNetworksManager(unittest.TestCase): + """ + Tests for the NetworksManager + """ + def setUpClass(self): + networks = __import__('resources/extra/networks') + instances = [getattr(networks, item)() for item in dir(networks) + if (not item.startswith('__') and item != 'NetworkOperator')] + populate_networks(instances, TMPFILE) + + def tearDownClass(self): + os.unlink(TMPFILE) + + def test_lookup_network(self): + """ + Test that looking up a known netid + """ + network = get_network_by_id("21401", TMPFILE) + self.assertEqual(network.name, 'Vodafone') + self.assertEqual(network.country, 'Spain') + + def test_lookup_inexistent_network(self): + """ + Test that looking up an unknown netid (6002 atm) returns None + """ + network = get_network_by_id("6002", TMPFILE) + self.assertEqual(network, None) + diff --git a/wader/test/test_simprotocol.py b/wader/test/test_simprotocol.py new file mode 100644 index 0000000..769b3ec --- /dev/null +++ b/wader/test/test_simprotocol.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Unittests for the SIM card + +You need to be authenticated before running the test suite +""" + +import dbus.mainloop.glib +dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + +from twisted.trial.unittest import TestCase, SkipTest +from twisted.python import log + +import wader.common.aterrors as E +from wader.common.startup import attach_to_serial_port +from wader.common.config import config +from wader.common.contact import Contact +from wader.common.oal import osobj +from wader.common.middleware import BasicNetworkOperator + +class TestSIMCard(TestCase): + """Test for SIM card functionality""" + + def setUp(self): + self.sconn = None + self.serial = None + self.device = None + + try: + d = osobj.hw_manager.get_devices() + def get_device_cb(devices): + device = devices[0] + self.device = attach_to_serial_port(device) + self.sconn = self.device.sconn + d2 = self.device.initialize() + d2.addCallback(lambda ign: self.sconn.delete_all_contacts()) + d2.addCallback(lambda ign: self.sconn.delete_all_sms()) + return d2 + + d.addCallback(get_device_cb) + return d + except: + log.err() + + def tearDown(self): + return self.device.close() + + def test_add_contact_with_index(self): + """ + Test adding a contact specifying an index + """ + contact = Contact("Vodafone", "+34670979779", 1) + d = self.sconn.add_contact(contact) + def process_contact_bak(ignored): + d2 = self.sconn.get_contact_by_index(contact.index) + d2.addCallback(lambda contact_bak2: + self.assertEqual(contact, contact_bak2)) + return self.sconn.delete_contact(contact.index) + + d.addCallback(process_contact_bak) + return d + + def test_add_contact_without_index(self): + """ + Test adding a contact without specifying an index + """ + contact = Contact("Vodafone", "+34670979779") + d = self.sconn.add_contact(contact) + def process_contact_bak(index): + self.assertEqual(index, 1) + d2 = self.sconn.get_contact_by_index(index) + d2.addCallback(lambda contact_bak2: + self.assertEqual(contact, contact_bak2)) + d2.addCallback(lambda ignored: self.sconn.delete_contact(index)) + return d2 + + d.addCallback(process_contact_bak) + return d + + def test_change_pin(self): + """ + Test that change pin works + """ + if not config: + raise SkipTest("Not config") + + oldpin = config.get('test', 'pin', '0000') + newpin = '1444' + def change_pin_cb(ignored): + # the pin has changed now, lets check if it really changed + # by disabling the new pin + d2 = self.sconn.enable_pin(newpin, False) + d2.addCallback(lambda ignored: self.sconn.enable_pin(newpin, True)) + d2.addCallback(lambda ignored: + self.sconn.change_pin(newpin, oldpin)) + return d2 + + d = self.sconn.change_pin(oldpin, newpin) + d.addCallback(change_pin_cb) + return d + + def test_change_pin_with_bad_pin_raises_error(self): + """ + Test that change pin works + """ + badoldpin = '5555' + newpin = '1444' + d = self.sconn.change_pin(badoldpin, newpin) + return self.failUnlessFailure(d, E.GenericError, E.IncorrectPassword) + + def test_delete_contact(self): + """ + Test deleting a contact + """ + contact = Contact("Vodafoo", "+3467456654", 2) + d = self.sconn.add_contact(contact) + def callback(ignored): + self.sconn.delete_contact(contact.index) + d2 = self.sconn.get_used_contact_ids() + d2.addCallback(lambda val: self.failIfIn(contact.index, val)) + return self.sconn.delete_contact(contact.index) + + d.addCallback(callback) + return d + + def test_disable_and_enable_pin(self): + """ + Tests that disable_pin and enable_pin works + + Also tests get_pin_status + """ + if not config: + raise SkipTest("Not config") + + pin = config.get('test', 'pin', '0000') + def disable_pin_cb(ignored): + self.sconn.get_pin_status().addCallback(lambda active: + self.assertEqual(active, 0)) + d2 = self.sconn.enable_pin(pin, True) + d2.addCallback(lambda _: + self.sconn.get_pin_status().addCallback(lambda active: + self.assertEqual(active, 1))) + return d2 + + d = self.sconn.enable_pin(pin, False) + d.addCallback(disable_pin_cb) + return d + + def test_find_contact(self): + """ + Test finding a contact + """ + contact = Contact("Vodafone", "+34670979779", 1) + pattern = "Vodafo" + self.sconn.add_contact(contact) + d = self.sconn.find_contacts(pattern) + def process_contact(contact_found): + self.assertEqual(contact, contact_found[0]) + return self.sconn.delete_contact(contact.index) + + d.addCallback(process_contact) + return d + + def test_get_charsets(self): + """ + Test that we can get the charsets in the card + + At least the IRA charset must be present + """ + def charset_cb(charsets): + self.assertIn('IRA', charsets) + #self.assertIn('GSM', charsets) + #self.assertIn('UCS2', charsets) + + d = self.sconn.get_charsets() + d.addCallback(charset_cb) + return d + + def test_get_contacts(self): + """ + Test that get_contacts works + + We add a couple of contacts and they must appear in get_contacts resp + """ + contact = Contact("Vodafone", "+34670979779", 3) + contact2 = Contact("Vodafoonz", "+34670923432", 4) + self.sconn.add_contact(contact) + self.sconn.add_contact(contact2) + def get_contacts_cb(contacts): + self.failUnlessIn(contact, contacts) + self.failUnlessIn(contact2, contacts) + + d = self.sconn.delete_contact(contact.index) + d.addCallback(lambda _: self.sconn.delete_contact(contact2.index)) + return d + + d = self.sconn.get_contacts() + d.addCallback(get_contacts_cb) + return d + + def test_get_contacts_empty(self): + """ + Test that get_contacts returns an empty list when there's no contacts + """ + d = self.sconn.get_contacts() + d.addCallback(lambda resp: self.assertEqual(resp, [])) + return d + + def test_get_imei(self): + """ + Test getting IMEI + + The IMEI is supposed to start with 35 + """ + + d = self.sconn.get_imei() + d.addCallback(lambda imei: self.failUnless(imei.startswith('35'))) + return d + + def test_get_imsi(self): + """ + Test getting IMSI + + The IMSI is supposed to start with 21401 + """ + + d = self.sconn.get_imsi() + d.addCallback(lambda imsi: self.failUnless(imsi.startswith('21401'))) + return d + + def test_set_and_get_netreg_notifications(self): + """ + Test that setting and getting netreg notifications works + """ + def set_netreg_notification_cb(ignored): + d2 = self.sconn.get_netreg_status() + d2.addCallback(lambda resp: self.assertEqual(resp[0], 1)) + return d2 + + d = self.sconn.set_netreg_notification() + d.addCallback(set_netreg_notification_cb) + return d + + def test_get_card_version(self): + """ + Test that card version can be get + + Only checks that sim returns a valid non-empty string. + """ + d = self.sconn.get_card_version() + def get_card_version_cb(version): + self.failUnless(isinstance(version, str)) + self.failUnless(len(version) > 0) + + d.addCallback(get_card_version_cb) + return d + + def test_get_manufacturer_name(self): + """ + Test that card manufacturer name can be get + + Only checks that sim returns a valid non-empty string. + """ + d = self.sconn.get_manufacturer_name() + def get_manufacturer_name_cb(name): + self.failUnless(isinstance(name, str)) + self.failUnless(len(name) > 0) + + d.addCallback(get_manufacturer_name_cb) + return d + + def test_get_network_info(self): + """ + Test AT+COPS? + """ + d = self.sconn.get_network_info() + def process_netinfo(netinfo): + """ + What can we test here? Just that we're registered + """ + netname, cell_type = netinfo + self.assertIn(cell_type, ['GPRS', '3G']) + if isinstance(netname, int): + d = self.sconn.get_imsi() + d.addCallback(lambda imsi: self.assertEqual(imsi[:5], netname)) + return d + + d.addCallback(process_netinfo) + return d + + def test_get_network_names(self): + """ + Test AT+COPS=? + + We're gonna check that an operator that starts with my IMSI is present + """ + def get_imsi_cb(imsi): + oper = BasicNetworkOperator(int(imsi[:5])) + d = self.sconn.get_network_names() + d.addCallback(lambda opers: self.assertIn(oper, opers)) + return d + + d = self.sconn.get_imsi() + d.addCallback(get_imsi_cb) + return d + + def test_get_roaming_ids(self): + """ + Test AT+CPOL? + + We'll test that we're receiving a valid list of BasicNetworkOperator + """ + d = self.sconn.get_roaming_ids() + def process_rids(rids): + self.failUnless(len(rids) > 1) + self.failUnless(isinstance(rids[0], BasicNetworkOperator)) + + d.addCallback(process_rids) + return d + + def test_get_signal_quality(self): + """ + Test the signal quality + + We cannot really test much here as the RSSI is not deterministic + """ + d = self.sconn.get_signal_quality() + def process_rssi(rssi): + self.failUnless(isinstance(rssi, int)) + self.failUnless(rssi >= 0) + + d.addCallback(process_rssi) + return d + + def test_get_smsc(self): + """ + Test getting SMSC + """ + if not config: + raise SkipTest("Not config") + + goodsmsc = config.get('test', 'smsc', '+34607003110') + d = self.sconn.get_smsc() + d.addCallback(lambda smsc: self.assertEqual(smsc, goodsmsc)) + return d + + def test_set_charset(self): + """ + Test the we can set the character set + + We set it to IRA, check that actually is stored as IRA, and then we + set it back to UCS2 and we check it again + """ + if self.device.sim.charset not in 'UCS2': + raise SkipTest("Not for this device") + + d = self.sconn.set_charset('IRA') + def set_charset_cb(ignored): + self.sconn.get_charset().addCallback(lambda charset: + self.assertEqual(charset, 'IRA')) + self.sconn.set_charset('UCS2') + d = self.sconn.get_charset() + d.addCallback(lambda charset: self.assertEqual(charset, 'UCS2')) + return d + + d.addCallback(set_charset_cb) + return d + + def test_set_network_info_format_numeric(self): + """ + Test that setting the network info format to numeric works + """ + self.sconn.set_network_info_format() + d = self.sconn.get_network_info() + d.addCallback(lambda netinfo: + self.assertTrue(isinstance(netinfo[0], int))) + return d + + def test_set_network_info_format_alphanumeric(self): + """ + Test that setting the network info format to alphanumeric works + """ + self.sconn.set_network_info_format(0, 0) + d = self.sconn.get_network_info() + d.addCallback(lambda netinfo: + self.assertTrue(isinstance(netinfo[0], str))) + return d + + def test_set_smsc(self): + """ + Test that the SMSC can be set + + We set it to a "bad" SMSC, check that it was effectively changed and + then we set the "good" SMSC again, checking again that the stored + SMSC is the "good" one + """ + if not config: + raise SkipTest("Not config") + + bogus_smsc = '+34646456451' + good_smsc = config.get('test', 'smsc', '+34607003110') + def process_smsc(ignored): + d2 = self.sconn.get_smsc() + d2.addCallback(lambda smsc: self.assertEqual(smsc, bogus_smsc)) + d2.addCallback(lambda _: self.sconn.set_smsc(good_smsc)) + return d2 + + d = self.sconn.set_smsc(bogus_smsc) + d.addCallback(process_smsc) + return d + diff --git a/wader/test/test_utils.py b/wader/test/test_utils.py new file mode 100644 index 0000000..82e7801 --- /dev/null +++ b/wader/test/test_utils.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2006-2008 Vodafone España, S.A. +# Copyright (C) 2008-2009 Warp Networks, S.L. +# Author: Pablo Martí +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +tests for the wader.common.utils module +""" + +import os +from random import shuffle, randint + +from twisted.trial import unittest + +from wader.common.utils import (get_file_data, save_file, natsort, + convert_ip_to_int, convert_int_to_ip, + rssi_to_percentage, flatten_list, + revert_dict) + +def ip_generator(n): + c = 0 + while c < n: + yield "%d.%d.%d.%d" % (randint(0, 255), randint(0, 255), + randint(0, 255), randint(0, 255)) + c += 1 + + +class TestUtilities(unittest.TestCase): + + def test_get_file_data(self): + """ + Test reading a random file with ``get_file_data`` + """ + text = os.urandom(2000) + path = '/tmp/file.foo' + fobj = open(path, 'w') + fobj.write(text) + fobj.close() + + self.assertEqual(text, get_file_data(path)) + os.unlink(path) + + def test_save_file(self): + """ + Tests that saving a random file works with ``save_file`` + """ + text = os.urandom(2000) + path = '/tmp/file.foo' + + save_file(path, text) + + fobj = open(path, 'r') + data = fobj.read() + fobj.close() + + self.assertEqual(text, data) + os.unlink(path) + + def test_natsort(self): + """ + Test that the ``natsort`` function works as expected + """ + l = [] + for i in range(15): + l.append("ttyUSB%d" % i) + + unordered = l[:] + shuffle(unordered) + + self.assertNotIdentical(l, unordered) + natsort(unordered) + self.assertEqual(l, unordered) + + def test_ip_to_int_conversion(self): + for ip in ip_generator(50000): + num = convert_ip_to_int(ip) + self.assertEqual(ip, convert_int_to_ip(num)) + + def test_problematic_int_conversion(self): + a, b = 1159778244, -1104335932 + ipa, ipb = "196.207.32.69", "196.43.45.190" + self.assertEqual(a, convert_ip_to_int(ipa)) + self.assertEqual(b, convert_ip_to_int(ipb)) + self.assertEqual(convert_int_to_ip(a), ipa) + self.assertEqual(convert_int_to_ip(b), ipb) + + def test_rssi_to_percentage(self): + self.assertEqual(rssi_to_percentage(31), 100) + self.assertEqual(rssi_to_percentage(32), 0) + self.assertEqual(rssi_to_percentage(0), 0) + + def test_flatten_list(self): + self.assertEqual(flatten_list([1, 2, [5, 6]]), [1, 2, 5, 6]) + self.assertEqual(flatten_list([1, 2, (5, 6)]), [1, 2, 5, 6]) + + self.assertEqual(flatten_list([1, iter([2, 3, 4])]), [1, 2, 3, 4]) + + def test_revert_dict(self): + self.assertEqual(revert_dict({'a' : 'b'}), {'b' : 'a'}) + self.assertEqual(revert_dict(dict(foo='bar')), dict(bar='foo')) +