├── .gitignore ├── COPYING ├── Makefile ├── README.rst ├── doc └── nm-wifi-webui.jpg ├── nm-wifi-webui.py ├── nm_wifi_webui ├── __init__.py ├── nm.py ├── secrets.py ├── utils.py └── webui.py ├── requirements.txt ├── static ├── css │ ├── bootstrap-switch.min.css │ ├── bootstrap.css │ ├── bootstrap.min.css │ ├── main.css │ ├── main.scss │ ├── noscript.css │ └── noscript.scss ├── favicon.ico ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ └── glyphicons-halflings-regular.woff └── js │ ├── main.coffee │ ├── main.js │ └── vendor │ ├── bootstrap-switch.min.js │ ├── bootstrap.min.js │ ├── jquery-2.1.0.min.js │ ├── modernizr-2.6.2-respond-1.1.0.min.js │ └── sockjs-1.1.1.min.js └── templates ├── index.html └── static_nm_redirect.html /.gitignore: -------------------------------------------------------------------------------- 1 | /secrets.bencode 2 | *.pyc 3 | *.pyo 4 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2012 Mike Kazantsev 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | JS_PATH=static/js 3 | JS_FILES=$(wildcard $(JS_PATH)/*.js) 4 | 5 | CSS_PATH=static/css 6 | CSS_FILES=$(wildcard $(CSS_PATH)/*.css) 7 | 8 | all: coffee sass 9 | 10 | coffee: $(JS_FILES) 11 | sass: $(CSS_FILES) 12 | 13 | %.js: %.coffee 14 | coffee -c $< 15 | 16 | %.css: %.scss 17 | sassc -I $(dir $<) $< >$@.new 18 | mv $@.new $@ 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | NetworkManager-WiFi-WebUI 2 | ========================= 3 | 4 | **Deprecation Notice:** 5 | 6 | | This project should only be useful for reference, and maybe to port it 7 | | to something more modern. I'd be surprised if it even still works as-is. 8 | 9 | Simple web (http/browser) interface for `NetworkManager 10 | `_ daemon to manage WiFi 11 | connections. 12 | 13 | Designed to work with JS enabled or not, dynamically updating through websockets 14 | (though currently xhr-streaming transport is forced, see notes below), http 15 | streaming, long-poll, jsonp or whatever other mechanism SockJS supports, if 16 | possible. 17 | 18 | Requirements for this UI are to be fairly lite/minimal, responsive, be able to 19 | enable WiFi, pick AP, connect/disconnect and get basic status/scan updates, 20 | so nothing fancy, can almost be considered to be a proof of concept. 21 | 22 | .. contents:: 23 | :backlinks: none 24 | 25 | 26 | Screenshot 27 | ---------- 28 | 29 | .. figure:: https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/master/doc/nm-wifi-webui.jpg 30 | :alt: nm-wifi-webui interface looks 31 | 32 | Uses bundled (old v3.1.1) bootstrap icons/css/js, bootstrap-switch, 33 | jquery/modernizr (both can probably be dropped by now), sockjs. 34 | Doesn't make any external api requests, no images or other static. 35 | 36 | 37 | Installation 38 | ------------ 39 | 40 | Process example (starting as root):: 41 | 42 | # useradd nm-wifi-webui 43 | # mkdir -m0700 ~nm-wifi-webui 44 | # chown -R nm-wifi-webui: ~nm-wifi-webui 45 | 46 | # mkdir -p /etc/polkit-1/rules.d/ 47 | # cat >/etc/polkit-1/rules.d/50-nm-wifi-webui.rules </etc/polkit-1/localauthority/50-local.d/nm-wifi-webui.pkla <`_ installed and running. 80 | * Python 2.7 81 | * `Twisted `_ 82 | * `SockJS-Twisted / txsockjs `_ 83 | * `Jinja2 `_ 84 | * `TxDBus `_ 85 | * `bencode `_ 86 | 87 | 88 | Notes 89 | ----- 90 | 91 | * Code is old python2, rusty and bitrotten. 92 | 93 | * Obviously, being a WebUI, this thing is only accessible through some kind of 94 | network interface (loopback counts), and at the same time is responsible for 95 | setting one up, so keep that in mind wrt potential uses. 96 | 97 | Common use-case is to show up in kiosk-mode browser on something like 98 | Raspberry Pi (until there's net connection), or be accessible over (not 99 | managed by NM) ethernet link. 100 | 101 | * Doesn't need any extra webserver, as it runs on twisted. 102 | 103 | * All communication with NM is done through DBus interface, so any permission 104 | errors there should be resolved either via ``/etc/dbus-1/system.d/*.conf`` 105 | files or ``/etc/polkit-1/rules.d/*.rules`` files. 106 | 107 | Daemon checks all permissions on start, and will exit immediately if any of 108 | them aren't unambiguous "yes". 109 | 110 | * Daemon registers its own "Secret Agent" and stores auth info in 111 | ``secrets.bencode`` file alongside main script by default. 112 | 113 | See also --secrets-file option. 114 | 115 | * When debugging DBus or websocket stuff, running script with --noise option can 116 | be useful, as it'd dump all traffic on these, as script is sending/receiving it. 117 | 118 | * Note that gtk3 NM frontend(s) (e.g. default GNOME applet) can be used as a 119 | webui too with GDK_BACKEND=broadway, see: 120 | https://developer.gnome.org/gtk3/stable/gtk-broadway.html 121 | -------------------------------------------------------------------------------- /doc/nm-wifi-webui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/doc/nm-wifi-webui.jpg -------------------------------------------------------------------------------- /nm-wifi-webui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | #-*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | 6 | from os.path import join, dirname 7 | import os, sys, signal 8 | 9 | 10 | def watchdog(interval): 11 | import time 12 | pid = os.getppid() 13 | 14 | got_reply = dict(pid=True) 15 | def _reply(sig, frm): got_reply[pid] = sig 16 | signal.signal(signal.SIGUSR2, _reply) 17 | 18 | while True: 19 | sleep_to = time.time() + interval 20 | while True: 21 | delay = sleep_to - time.time() 22 | if delay <= 0: break 23 | try: time.sleep(delay) 24 | except: return 25 | if not got_reply: 26 | os.kill(pid, signal.SIGABRT) 27 | return 28 | got_reply.clear() 29 | try: 30 | os.kill(pid, 0) 31 | os.kill(pid, signal.SIGUSR2) 32 | except OSError: return 33 | 34 | def watchdog_reply_setup(pid): 35 | from twisted.internet import reactor 36 | def _reply(sig, frm): 37 | reactor.callFromThread(reactor.callLater, 0, os.kill, pid, signal.SIGUSR2) 38 | signal.signal(signal.SIGUSR2, _reply) 39 | 40 | 41 | def main(argv=None): 42 | import argparse 43 | parser = argparse.ArgumentParser( 44 | description='Discoverable network manager WebUI.') 45 | 46 | parser.add_argument('-p', '--httpd-port', 47 | metavar='port', type=int, default=8080, 48 | help='Port to bind WebUI to (default: %(default)s).') 49 | parser.add_argument('--httpd-static', 50 | metavar='path', default=join(dirname(__file__), 'static'), 51 | help='Path to static web assets (default: %(default)s).') 52 | parser.add_argument('--httpd-templates', 53 | metavar='path', default=join(dirname(__file__), 'templates'), 54 | help='Path to templates (default: %(default)s).') 55 | 56 | parser.add_argument('--secrets-file', 57 | metavar='path', default=join(dirname(__file__), 'secrets.bencode'), 58 | help='Path to file were all secrets will be stored (default: %(default)s).') 59 | 60 | parser.add_argument('-w', '--watchdog-ping-interval', 61 | metavar='seconds', type=float, default=10, 62 | help='Interval between checks if main process is responsive (default: %(default)s).') 63 | 64 | # XXX: add manhole 65 | parser.add_argument('-l', '--only-logger', metavar='logger_name', 66 | help='Only display logging stream from specified' 67 | ' logger name (example: nm.core) and errors from twisted logger.') 68 | parser.add_argument('--debug-memleaks', action='store_true', 69 | help='Import guppy and enable its manhole to debug memleaks (requires guppy module).') 70 | parser.add_argument('--debug-deferreds', action='store_true', 71 | help='Set debug mode for deferred objects to produce long tracebacks for unhandled errbacks.') 72 | parser.add_argument('--debug', action='store_true', help='Verbose operation mode.') 73 | parser.add_argument('--noise', action='store_true', 74 | help='In addition to --debug also dump e.g. all sent/received messages.') 75 | opts = parser.parse_args(argv or sys.argv[1:]) 76 | 77 | # Forked watchdog pid makes sure that twisted reactor isn't stuck (on e.g. blocking call) 78 | pid = os.fork() 79 | if not pid: 80 | watchdog(opts.watchdog_ping_interval) 81 | sys.exit(0) 82 | watchdog_reply_setup(pid) 83 | 84 | from nm_wifi_webui.webui import WebUI 85 | from nm_wifi_webui.nm import NMInterface 86 | from nm_wifi_webui import utils 87 | 88 | from twisted.internet import reactor, defer 89 | from twisted.web import resource, server 90 | from twisted.application import strports, service 91 | from twisted.python.filepath import FilePath 92 | 93 | import logging 94 | 95 | log = dict() 96 | if opts.only_logger: log['one_logger'] = opts.only_logger 97 | utils.init_logging(debug=opts.debug, noise=opts.noise, **log) 98 | log = logging.getLogger('interface.core') 99 | 100 | if opts.debug_memleaks: 101 | import guppy 102 | from guppy.heapy import Remote 103 | Remote.on() 104 | if opts.debug_deferreds: defer.Deferred.debug = True 105 | 106 | app = service.MultiService() 107 | webui = WebUI(static_path=opts.httpd_static, templates_path=opts.httpd_templates) 108 | webui.putChild('', webui) 109 | 110 | site = server.Site(webui) 111 | site.noisy = False 112 | site.displayTracebacks = False 113 | strports.service('tcp:{}'.format(opts.httpd_port), site).setServiceParent(app) 114 | 115 | nm = NMInterface(opts.secrets_file, webui) 116 | nm.setServiceParent(app) 117 | 118 | app.startService() 119 | reactor.addSystemEventTrigger('before', 'shutdown', app.stopService) 120 | 121 | log.debug('Starting...') 122 | reactor.run() 123 | log.debug('Finished (exit code: %s)', utils.exit_code) 124 | 125 | return utils.exit_code 126 | 127 | if __name__ == '__main__': sys.exit(main()) 128 | -------------------------------------------------------------------------------- /nm_wifi_webui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/nm_wifi_webui/__init__.py -------------------------------------------------------------------------------- /nm_wifi_webui/nm.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from nm_wifi_webui import utils, secrets 6 | 7 | from txdbus import ( 8 | error as txdbus_error, client as txdbus_client, 9 | objects as txdbus_objects, interface as txdbus_iface, 10 | marshal as txdbus_marshal ) 11 | 12 | from twisted.internet import reactor, defer, task 13 | from twisted.application import service 14 | 15 | import itertools as it, operator as op, functools as ft 16 | from weakref import WeakKeyDictionary 17 | from collections import namedtuple 18 | import os, sys, types, logging, hashlib, copy, uuid 19 | import socket, struct, time 20 | 21 | 22 | def ntop( n, af, none_for_0=False, 23 | _afid={4: socket.AF_INET, 6: socket.AF_INET6} ): 24 | assert af in [4, 6], af 25 | if none_for_0 and n == 0: return None 26 | if af == 4: n = struct.pack('@I', n) 27 | elif af == 6: n = ''.join(map(chr, n)) 28 | return socket.inet_ntop(_afid[af], n) 29 | 30 | 31 | class NMAP(namedtuple( 'NMAP', 32 | 'dbus_path ssid private mode sec strength hwaddr bitrate pass_state auto' )): 33 | 34 | _uid = _uuid = None 35 | uuid_generation_ns = uuid.UUID('cbc481a169650cf35627bf4cc654bdb8') 36 | 37 | @classmethod 38 | def get_uid(cls, dbus_path): 39 | return hashlib.sha512(dbus_path).hexdigest()[:10] 40 | 41 | @classmethod 42 | def get_uuid(cls, hwaddr): 43 | return bytes(uuid.uuid3(cls.uuid_generation_ns, utils.force_bytes(hwaddr))) 44 | 45 | @property 46 | def uid(self): 47 | 'NMAP identifier that makes sense within one pid/NM run.' 48 | if not self._uid: self._uid = self.get_uid(self.dbus_path) 49 | return self._uid 50 | 51 | @property 52 | def uuid(self): 53 | 'NMAP identifier that makes persists after pid/NM restarts.' 54 | if not self._uuid: self._uuid = self.get_uuid(self.hwaddr) 55 | return self._uuid 56 | 57 | @property 58 | def data(self): 59 | data = self._asdict() 60 | data['uid'] = self.uid 61 | return data 62 | 63 | 64 | 65 | class NMError(Exception): pass 66 | 67 | class NMConnectionError(NMError): pass 68 | 69 | class NMActionError(NMError): 70 | 71 | dbus_err_name = None 72 | 73 | def __init__(self, msg_or_dbus_err=None): 74 | args = list() 75 | if msg_or_dbus_err is not None: 76 | if isinstance(msg_or_dbus_err, txdbus_error.RemoteError): 77 | args.append(msg_or_dbus_err.message) 78 | self.dbus_err_name = msg_or_dbus_err.errName 79 | else: args.append(msg_or_dbus_err) 80 | super(NMActionError, self).__init__(*args) 81 | 82 | 83 | class NMInterface(service.MultiService): 84 | 85 | def __init__(self, secrets_file, webui): 86 | service.MultiService.__init__(self) 87 | self.log = logging.getLogger('nm.core') 88 | 89 | self.secrets = secrets.SecretStorage(secrets_file) 90 | self.dbus = DBusProxy(self.secrets) 91 | 92 | self.webui, webui.nm = webui, self 93 | 94 | self.wifi_init() 95 | 96 | self.dev_up, self.dev_signals = dict(), dict() 97 | 98 | 99 | def startService(self): 100 | reactor.callLater(0, self._startService) 101 | 102 | @defer.inlineCallbacks 103 | def _startService(self): 104 | yield self.dbus.nm.signal('DeviceAdded', self.iface_added, pass_ref=False) 105 | yield self.dbus.nm.signal('DeviceRemoved', self.iface_removed, pass_ref=False) 106 | yield self.dbus.nm.signal( 'PropertiesChanged', self.wifi_changed, 107 | iface='org.freedesktop.DBus.Properties', pass_ref=False ) 108 | 109 | yield self.wifi_conn_enabled_set() 110 | yield self.iface_detect() 111 | if not self.wifi_iface: 112 | self.log.debug('Unable to detect valid wifi interface, waiting for one to become available') 113 | 114 | 115 | NMError = NMError 116 | NMConnectionError = NMConnectionError 117 | NMActionError = NMActionError 118 | 119 | wifi_iface = wifi_dev = None 120 | wifi_caps, wifi_aps = list(), dict() 121 | wifi_signals, wifi_signals_ap = list(), dict() 122 | 123 | wifi_sec = set(['none', 'wep', 'wpa-tkip', 'wpa-ccmp']) 124 | wifi_sec_dict = dict( 125 | none=0x0, cipher_wep40=0x1, cipher_wep104=0x2, 126 | cipher_tkip=0x4, cipher_ccmp=0x8, wpa=0x10, rsn=0x20 ) 127 | wifi_ap_flags = dict(none=0 , privacy=0x1) 128 | wifi_ap_sec_dict = dict( none=0x0, 129 | pair_wep40=0x1, pair_wep104=0x2, 130 | pair_tkip=0x4, pair_ccmp=0x8, 131 | group_wep40=0x10, group_wep104=0x20, 132 | group_tkip=0x40, group_ccmp=0x80, 133 | key_mgmt_psk=0x100, key_mgmt_802_1x=0x200 ) 134 | wifi_ap_modes = set(['unknown', 'adhoc', 'infrastructure']) 135 | 136 | wifi_pass_state = set([None, 'success', 'error']) 137 | 138 | wifi_conn_res = set([ 139 | 'idle_disconnected', 'idle_unavailable', 140 | 'live_init', 'live_connect', 'live_config', # connect = prepare-auth, config = ip_* and later 141 | 'done', 142 | 'fail_auth', 'fail_addr', 'fail_link', 'fail' ]) 143 | wifi_conn_res_dict = dict( 144 | prepare='connect', config='connect', need_auth='connect', 145 | ip_config='config', ip_check='config', secondaries='config' ) 146 | wifi_conn_res_text = dict( 147 | idle_disconnected='Idle', idle_unavailable='Unavailable', 148 | live_init='Initiating connection', 149 | live_connect='Authenticating', live_config='Configuring network parameters', 150 | done='Idle', 151 | fail_auth='Authentication failed', fail_addr='Network configuration failed', 152 | fail_link='Network became unreachable', fail='Association failed' ) 153 | 154 | wifi_conn_enabled = None 155 | wifi_conn_res_reqs = list() 156 | wifi_conn_res_code = None 157 | wifi_conn_res_ap = None 158 | wifi_conn_res_act = 'Idle' 159 | wifi_conn_res_last = 'Disconnected' 160 | _wifi_conn_res_fail_reason = None # used only for logging 161 | _wifi_conn_res_config = None 162 | 163 | wifi_iface_detect_lock = defer.DeferredLock() 164 | wifi_iface_detect_task = None 165 | wifi_iface_detect_delay = 2 166 | 167 | dev_up, dev_signals = dict(), dict() 168 | iface_set = set() 169 | 170 | 171 | @property 172 | def wifi_dev_path(self): 173 | if not self.wifi_dev: return None 174 | return self.wifi_dev.path 175 | 176 | @property 177 | def wifi_conn_res_config(self): 178 | if self.wifi_conn_res_code != 'done': return 179 | return self._wifi_conn_res_config 180 | 181 | 182 | def _iface_detect_lock(func): 183 | @ft.wraps(func) 184 | @defer.inlineCallbacks 185 | def _wrapper(self, *args, **kws): 186 | yield self.wifi_iface_detect_lock.acquire() 187 | try: yield func(self, *args, **kws) 188 | finally: self.wifi_iface_detect_lock.release() 189 | return _wrapper 190 | 191 | @defer.inlineCallbacks 192 | def iface_detect(self): 193 | dev_list = yield self.dbus.nm.GetDevices() 194 | assert not self.wifi_iface, self.wifi_iface 195 | for dev_path in set(dev_list).difference(self.iface_set): 196 | yield self.iface_added(dev_path) 197 | 198 | @_iface_detect_lock 199 | @defer.inlineCallbacks 200 | def iface_added(self, dev_path): 201 | self.iface_set.add(dev_path) 202 | 203 | dev = self.dbus.ref('Device', dev_path) 204 | iface = yield dev.get('Interface') 205 | iface_type = yield dev.get('DeviceType') 206 | iface_state = yield dev.get('State') 207 | 208 | self.log.debug('Detected %r interface: %s (%s)', iface_type, iface, iface_state) 209 | 210 | if dev_path not in self.dev_signals: 211 | self.dev_signals.setdefault(dev_path, set()).add( 212 | (yield dev.signal('StateChanged', self.iface_changed)) ) 213 | 214 | if iface_state == 'activated': # for all interfaces, not just wifi 215 | # yield self.hook_iface_up(iface, dev) 216 | self.dev_up[dev_path] = iface 217 | 218 | if iface_type != 'wifi': defer.returnValue(None) 219 | 220 | def skip(reason, level='warn'): 221 | getattr(self.log, level)('Skipping wifi interface (reason: %s): %s', reason, iface) 222 | defer.returnValue(None) 223 | 224 | if self.wifi_iface: skip('extra') 225 | 226 | sec_id = yield dev.get('WirelessCapabilities', iface='Device.Wireless') 227 | yield self.wifi_added(iface, dev, sec_id) 228 | 229 | @_iface_detect_lock 230 | def iface_changed(self, dev, new_state, old_state, reason): 231 | return self.iface_changed_apply(dev, new_state, old_state, reason) 232 | 233 | @defer.inlineCallbacks 234 | def iface_changed_apply(self, dev, new_state, old_state=None, reason=None): 235 | new_state, old_state = map( 236 | ft.partial(dev.interpret_prop_value, 'State'), 237 | [new_state, old_state] ) 238 | reason = dev.interpret_prop_value('Reason', reason) 239 | iface = yield dev.get('Interface') 240 | iface_type = yield dev.get('DeviceType') 241 | dev_is_wifi = dev.path == self.wifi_dev_path 242 | 243 | log_status = lambda msg, level='debug':\ 244 | getattr(self.log, level)( 245 | '%s (dev: %s/%s): %s -> %s, %s', 246 | msg, iface_type, iface, old_state, new_state, reason ) 247 | log_status('Interface state-change') 248 | 249 | if new_state == old_state: 250 | log_status('Skipping null state-change') 251 | defer.returnValue(None) 252 | 253 | # Hooks for active interfaces 254 | if new_state == 'activated': 255 | if dev.path not in self.dev_up: 256 | # yield self.hook_iface_up(iface, dev) 257 | self.dev_up[dev.path] = iface 258 | elif old_state == 'activated': 259 | if dev.path in self.dev_up: 260 | iface_old = self.dev_up.pop(dev.path) 261 | # yield self.hook_iface_down(iface_old) 262 | 263 | if dev_is_wifi: 264 | # React to external "on/off" switching 265 | if new_state == 'unmanaged': # reason=sleeping 266 | yield self.wifi_conn_enabled_set(False) 267 | elif old_state == 'unmanaged': # reason=now_managed 268 | yield self.wifi_conn_enabled_set(True) 269 | 270 | # Auth-fail scenario - WPA/RSN: 271 | # 02:26:35 :: disconnected -> prepare, unknown 272 | # 02:26:35 :: prepare -> config, unknown 273 | # 02:26:35 :: config -> need_auth, unknown 274 | # dbus_GetSecrets 275 | # 02:26:35 :: need_auth -> prepare, unknown 276 | # 02:26:35 :: prepare -> config, unknown 277 | # 02:26:40 :: config -> need_auth, supplicant_disconnect 278 | # dbus_GetSecrets not-saved 279 | # 02:26:40 :: need_auth -> failed, no_secrets 280 | # 02:26:40 :: failed -> disconnected, unknown 281 | 282 | # Auth-fail scenario - WEP: 283 | # 05:29:58 :: prepare -> config, unknown 284 | # 05:29:58 :: config -> need_auth, unknown 285 | # dbus_GetSecrets 286 | # 05:29:58 :: need_auth -> prepare, unknown 287 | # 05:29:58 :: prepare -> config, unknown 288 | # 05:30:24 :: config -> failed, ssid_not_found 289 | # 05:30:24 :: failed -> disconnected, unknown 290 | 291 | # Auth-success: 292 | # 16:09:55 :: need_auth -> prepare, unknown 293 | # 16:09:55 :: prepare -> config, unknown 294 | # 16:09:56 :: config -> ip_config, unknown 295 | # fail: 296 | # 16:10:26 :: ip_config -> failed, config_unavailable 297 | # 16:10:26 :: failed -> disconnected, unknown 298 | # success: 299 | # 16:09:57 :: ip_config -> secondaries, unknown 300 | # 16:09:57 :: secondaries -> activated, unknown 301 | 302 | if new_state == 'failed': 303 | self.wifi_conn_res_fail_reason = reason 304 | if old_state == 'need_auth' and reason == 'no_secrets': 305 | self.wifi_update_ap(uid=self.wifi_conn_res_ap.uid, pass_state='error') 306 | # Redundant with DBusSecretAgent getting GetSecrets with not-saved flag 307 | self.secrets.update(self.wifi_conn_res_ap.uuid, state='error') 308 | 309 | elif new_state == 'disconnected': 310 | if self.wifi_conn_res_code == 'live_connect': 311 | self.wifi_status_update_fail('auth') 312 | elif self.wifi_conn_res_code == 'live_config': 313 | self.wifi_status_update_fail('addr') 314 | elif self.wifi_conn_res_code is None: 315 | self.wifi_status_update('idle_disconnected', 'Disconnected') 316 | elif self.wifi_conn_res_code == 'activated': pass # nm bug? sent after disconnect-connect 317 | elif reason == 'user_requested' or old_state == 'unavailable': pass 318 | elif self.wifi_conn_enabled: 319 | self.log.warn( 320 | 'Unhandled disconnection reason transition: %s -> %s, %s (last fail reason: %s)', 321 | old_state, new_state, reason, self.wifi_conn_res_fail_reason ) 322 | 323 | elif new_state in self.wifi_conn_res_dict: # "in-progress" 324 | if self.wifi_conn_res_dict[new_state] != self.wifi_conn_res_dict.get(old_state): 325 | if old_state == 'config' and new_state == 'ip_config': 326 | self.wifi_update_ap(uid=self.wifi_conn_res_ap.uid, pass_state='success') 327 | self.secrets.update(self.wifi_conn_res_ap.uuid, state='success') 328 | elif old_state == 'disconnected': 329 | if not (yield self.wifi_status_update_ap()): 330 | defer.returnValue(None) 331 | self.wifi_status_update_live(self.wifi_conn_res_dict[new_state]) 332 | 333 | elif new_state == 'activated': 334 | config = yield self.wifi_dev_config() 335 | self.wifi_status_update_done(self.wifi_conn_res_ap, config) 336 | 337 | elif new_state == 'unmanaged'\ 338 | or old_state == 'unmanaged'\ 339 | or old_state == 'unknown': pass 340 | 341 | else: 342 | self.log.warn( 'Unhandled connection state' 343 | ' transition: %s -> %s, %s', old_state, new_state, reason ) 344 | 345 | @_iface_detect_lock 346 | @defer.inlineCallbacks 347 | def iface_removed(self, dev_path): 348 | self.iface_set.discard(dev_path) 349 | for sig_ref in self.dev_signals.get(dev_path, set()): yield sig_ref.release() 350 | if dev_path in self.dev_up: 351 | iface_old = self.dev_up.pop(dev_path) 352 | # yield self.hook_iface_down(iface_old) 353 | if dev_path == self.wifi_dev_path: 354 | self.wifi_removed('iface_removed') 355 | 356 | def wifi_init(self): 357 | self.log.debug('WiFi state init') 358 | 359 | for ap in self.wifi_aps: self.wifi_remove_ap(ap) 360 | for sig_ref in self.wifi_signals: reactor.callLater(0, sig_ref.release) 361 | for sig_ref_set in self.wifi_signals_ap.viewvalues(): 362 | for sig_ref in sig_ref_set: reactor.callLater(0, sig_ref.release) 363 | self.wifi_signals, self.wifi_signals_ap = list(), dict() 364 | 365 | self.wifi_iface = self.wifi_dev = None # str/ref 366 | self.wifi_caps, self.wifi_aps = list(), dict() 367 | 368 | for req in self.wifi_conn_res_reqs: req.cancel() 369 | self.wifi_conn_res_reqs = list() 370 | 371 | @defer.inlineCallbacks 372 | def wifi_dev_config_get(self, dev): 373 | config = dict(nameservers=set()) 374 | for n in 4, 6: 375 | conf_path = yield dev.get('Ip{}Config'.format(n)) 376 | if conf_path != '/': 377 | conf_obj = self.dbus.ref( 'IP{}Config'.format(n), conf_path) 378 | addrs = yield conf_obj.get('Addresses') 379 | addrs = list( 380 | (ntop(addr, n), prefix, ntop(gw, n, none_for_0=True)) 381 | for addr, prefix, gw in addrs ) 382 | config['nameservers'].update( 383 | ntop(addr, n) for addr in (yield conf_obj.get('Nameservers')) ) 384 | else: addrs = list() 385 | config['ipv{}_addrs'.format(n)] = addrs 386 | defer.returnValue(config) 387 | 388 | @defer.inlineCallbacks 389 | def wifi_dev_config(self): 390 | config = yield self.wifi_dev_config_get(self.wifi_dev) 391 | self._wifi_conn_res_config = config 392 | defer.returnValue(config) 393 | 394 | def wifi_sec_translate(self, sec_id): 395 | caps = set(['none']) 396 | cap = lambda cap: sec_id & self.wifi_sec_dict[cap] 397 | if cap('wpa') or cap('rsn'): 398 | if cap('cipher_tkip'): caps.add('wpa-tkip') 399 | if cap('cipher_ccmp'): caps.add('wpa-ccmp') 400 | if cap('cipher_wep40') or cap('cipher_wep104'): caps.add('wep') 401 | return caps 402 | 403 | def wifi_ap_sec_translate(self, sec_id): 404 | cap = lambda cap: sec_id & self.wifi_ap_sec_dict[cap] 405 | key = cipher = None 406 | if cap('key_mgmt_psk'): key, cipher = 'psk', 'pair' 407 | elif cap('key_mgmt_802_1x'): key, cipher = 'enterprise', 'group' 408 | if key: 409 | # WEP doesn't seem to be ever triggered here, 410 | # APs with it just have privacy=True and sec_id=0 411 | if cap('{}_wep40'.format(cipher)) or cap('{}_wep104'.format(cipher)): cipher = 'wep' 412 | elif cap('{}_tkip'.format(cipher)): cipher = 'wpa-tkip' 413 | elif cap('{}_ccmp'.format(cipher)): cipher = 'wpa-ccmp' 414 | if key != 'psk': cipher = 'enterprise-{}'.format(cipher) # marks it as unsupported 415 | return cipher or 'none' 416 | 417 | @defer.inlineCallbacks 418 | def wifi_update_ap_auto(self, uid, value, action=False): 419 | ap = self.wifi_aps[uid] 420 | conn_obj = self.dbus.ref( 'Settings.Connection', 421 | (yield self.dbus.nm_settings.GetConnectionByUuid(ap.uuid)) ) 422 | settings = yield conn_obj.GetSettings() 423 | settings['connection']['autoconnect'] = value 424 | yield conn_obj.Update(dbus_variant_sanitize(settings)) 425 | self.wifi_update_ap(uid=uid, auto=value, action=action) 426 | 427 | def wifi_update_ap_lock(self, ap_path): 428 | self.wifi_aps[NMAP.get_uid(ap_path)] = defer.Deferred() 429 | 430 | def wifi_update_ap(self, uid=None, force_new=False, action=False, **ap_params): 431 | self.log.debug('Updating AP parameters (uid: %s): %s', uid, ap_params) 432 | d = None 433 | if uid is None: 434 | if 'pass_state' not in ap_params: 435 | uuid = NMAP.get_uuid(ap_params['hwaddr']) 436 | # Doesn't acknowledge passphrases that are stored but not validated 437 | ap_params['pass_state'] = self.secrets.get(uuid, 'state') 438 | else: uuid = None 439 | ap = NMAP(**ap_params) 440 | uid = ap.uid 441 | d = self.wifi_aps.get(uid) 442 | if isinstance(d, defer.Deferred): del self.wifi_aps[uid] 443 | else: d = None 444 | assert not uuid or ap.uuid == uuid, [ap.uuid, uuid] 445 | assert not force_new or uid not in self.wifi_aps, [uid, ap_params] 446 | else: 447 | if isinstance(self.wifi_aps.get(uid), defer.Deferred): return # locked 448 | assert not force_new, [uid, ap_params] 449 | assert 'dbus_path' not in ap_params, ap_params 450 | assert uid in self.wifi_aps, [uid, ap_params] 451 | if uid in self.wifi_aps: 452 | ev, ap = 'update', self.wifi_aps[uid].data 453 | ap.update(ap_params) 454 | del ap['uid'] 455 | ap = NMAP(**ap) 456 | assert ap.uid == uid, [ap.uid, uid] 457 | else: 458 | ev = 'new' 459 | assert ap.sec in self.wifi_sec, [ap.sec, self.wifi_sec] 460 | assert ap.mode in self.wifi_ap_modes, ap.mode 461 | assert ap.pass_state in self.wifi_pass_state, ap.pass_state 462 | self.wifi_aps[ap.uid] = ap 463 | if not action: self.webui.handle_ap_update(ev, ap) 464 | if d: d.callback(ap) 465 | return ap.uid 466 | 467 | def wifi_remove_ap(self, uid_or_ap): 468 | if isinstance(uid_or_ap, NMAP): uid_or_ap = uid_or_ap.uid 469 | ap = self.wifi_aps.pop(uid_or_ap, None) 470 | if ap: self.webui.handle_ap_update('remove', ap) 471 | 472 | @defer.inlineCallbacks 473 | def wifi_aps_list(self): # XXX: maybe just don't put deferreds in there? 474 | aps = list() 475 | for k in self.wifi_aps.keys(): 476 | aps.append((yield self.wifi_aps[k])) 477 | defer.returnValue(aps) 478 | 479 | 480 | @defer.inlineCallbacks 481 | def wifi_disconnect(self, action=False): 482 | if not (self.wifi_iface and self.wifi_dev): return 483 | if action: 484 | self.log.debug('Initiating disconnect from AP') 485 | # yield self.hook_iface_down(self.wifi_dev.path) 486 | try: yield self.wifi_dev.Disconnect() 487 | except txdbus_error.RemoteError as err: 488 | if err.errName != nm_iface_full('Device.UnknownConnection'): raise 489 | raise NMActionError(err) 490 | self.wifi_status_update_disconnect() 491 | 492 | @defer.inlineCallbacks 493 | def wifi_scan(self, reason='manual_request'): 494 | # XXX: crashes NetworkManager-0.9.8.8 with 6/ABRT, lol 495 | # Shouldn't be critical, as it does periodic scans on its own, 496 | # and nm-applet doesn't even have the button 497 | # XXX: maybe remove the button? 498 | # if self.wifi_dev: 499 | # self.log.debug('Initiating AP scan (%s, reason: %s)', self.wifi_iface, reason) 500 | # yield self.wifi_dev.RequestScan(dict(), _nm_interface='Device.Wireless') 501 | yield None 502 | 503 | @defer.inlineCallbacks 504 | def wifi_changed(self, props): 505 | if 'NetworkingEnabled' in props: 506 | yield self.wifi_conn_enabled_set(props['NetworkingEnabled']) 507 | self.log.debug('NM props update: %s', props) 508 | 509 | @defer.inlineCallbacks 510 | def wifi_conn_enabled_set(self, val=None, action=False): 511 | if val is None: val = yield self.dbus.nm.get('NetworkingEnabled') 512 | else: 513 | assert isinstance(val, bool), val 514 | if action: self.log.debug('Switching to %s mode', 'online' if val else 'offline') 515 | self.wifi_conn_enabled = val 516 | if not val: 517 | try: yield self.wifi_disconnect(action=action) 518 | except NMActionError: pass 519 | try: yield self.dbus.nm.Enable(val) 520 | except txdbus_error.RemoteError as err: 521 | if err.errName != nm_iface_full('AlreadyEnabledOrDisabled'): raise 522 | else: yield self.webui.handle_online_update(val) 523 | 524 | 525 | @defer.inlineCallbacks 526 | def wifi_added(self, iface, dev, sec_id): 527 | 'Use new WiFi interface.' 528 | self.wifi_iface, self.wifi_dev = iface, dev 529 | self.wifi_sec = self.wifi_sec_translate(sec_id) 530 | 531 | if self.wifi_iface_detect_task: 532 | self.wifi_iface_detect_task.stop() 533 | self.wifi_iface_detect_task = None 534 | reactor.callLater(0, self.wifi_scan, 'iface_added') 535 | 536 | assert not self.wifi_signals, self.wifi_signals 537 | self.wifi_signals.extend([ 538 | (yield dev.signal( 'AccessPointAdded', 539 | self.wifi_update_ap_added, iface='Device.Wireless' )), 540 | (yield dev.signal( 'AccessPointRemoved', 541 | self.wifi_update_ap_removed, iface='Device.Wireless' )), 542 | (yield dev.signal( 'PropertiesChanged', 543 | self.wifi_changed, iface='org.freedesktop.DBus.Properties' )) ]) 544 | 545 | aps = yield dev.GetAccessPoints(_nm_interface='Device.Wireless') 546 | for ap_path in aps: yield self.wifi_update_ap_added(dev, ap_path) 547 | 548 | self.log.debug( 'Using new wifi interface:' 549 | ' %s (capabilities: %s)', self.wifi_iface, ', '.join(self.wifi_sec) ) 550 | yield self.wifi_status_update_init() 551 | 552 | # @defer.inlineCallbacks 553 | def wifi_changed( self, dev, dbus_iface, props, 554 | props_missing=None, _should_not_change=['Autoconnect', 'PermHwAddress'] ): 555 | for k, v in props.viewitems(): 556 | if k in _should_not_change: 557 | self.log.warn('Unexpected dev property change: %r = %r', k, v) 558 | elif k == 'WirelessCapabilities': 559 | self.wifi_sec = self.wifi_sec_translate(v) # XXX: UI update? 560 | # XXX: elif k == 'Bitrate': - not displayed currently 561 | # XXX: elif k == 'ActiveAccessPoint': - ap signals used instead 562 | 563 | @defer.inlineCallbacks 564 | def wifi_removed(self, reason='unspecified'): 565 | 'Scrap currently-used WiFi interface.' 566 | self.log.debug( 'Resetting wifi interface (%s,' 567 | ' reason: %s), scheduling detection task', self.wifi_iface, reason ) 568 | yield self.wifi_conn_enabled_set(False) 569 | yield self.wifi_init() 570 | if not self.wifi_iface_detect_task: 571 | self.wifi_iface_detect_task = task.LoopingCall(self.iface_detect) 572 | self.wifi_iface_detect_task.start(self.wifi_iface_detect_delay, now=True) 573 | 574 | 575 | def wifi_status_update(self, code, status, action=None, ap=None, config=None): 576 | assert code in self.wifi_conn_res, code 577 | assert code != 'done' or (ap and config), [code, ap, config] 578 | # live_* and fail_* only make sense UI-wise with ap 579 | assert not code or not (( code.startswith('fail_') 580 | or code.startswith('live_') ) and not ap), [code, ap] 581 | if action is None: action = self.wifi_conn_res_text[code] 582 | self.log.debug( 'Status update: %s, %s' 583 | ' (code: %s, ap: %s, config: %s)', status, action, code, ap, config ) 584 | self.wifi_conn_res_code, self.wifi_conn_res_ap = code, ap 585 | self.wifi_conn_res_last, self.wifi_conn_res_act = status, action 586 | self.webui.handle_status_update(status, action, code, ap, config) 587 | return code 588 | 589 | def wifi_status_update_done(self, ap, config): 590 | self.wifi_conn_res_fail_reason = None 591 | status = 'Connected to "{}"'.format(ap.ssid) 592 | res = self.wifi_status_update('done', status, ap=ap, config=config) 593 | reqs, self.wifi_conn_res_reqs = self.wifi_conn_res_reqs, list() 594 | for d in reqs: d.callback(res) 595 | return res 596 | 597 | def wifi_status_update_disconnect(self): 598 | self.wifi_conn_res_fail_reason = None 599 | return self.wifi_status_update('idle_disconnected', 'Disconnected') 600 | 601 | def wifi_status_update_fail(self, code, status='Disconnected', action=None, ap=None): 602 | if ap is None: ap = self.wifi_conn_res_ap 603 | if not code.startswith('fail_'): code = 'fail_{}'.format(code) 604 | res = self.wifi_status_update(code, status, action, ap) 605 | reqs, self.wifi_conn_res_reqs = self.wifi_conn_res_reqs, list() 606 | for d in reqs: d.errback(NMConnectionError(res)) 607 | return res 608 | 609 | def wifi_status_update_live(self, code, ap=None): 610 | if ap is None: ap = self.wifi_conn_res_ap 611 | assert ap 612 | if not code.startswith('live_'): code = 'live_{}'.format(code) 613 | status = 'Connecting to "{}"'.format(ap.ssid) 614 | return self.wifi_status_update(code, status, ap=ap) 615 | 616 | @defer.inlineCallbacks 617 | def wifi_status_update_ap(self): 618 | conn_path = yield self.wifi_dev.get('ActiveConnection') 619 | if conn_path != '/': 620 | self.log.debug('Found Active AP: %s', conn_path) 621 | ap_path = yield self.dbus.ref('Connection.Active', conn_path).get('SpecificObject') 622 | self.wifi_conn_res_ap = yield self.wifi_aps[NMAP.get_uid(ap_path)] 623 | else: 624 | self.log.debug('No Active AP detected') 625 | defer.returnValue(self.wifi_conn_res_ap) 626 | 627 | @_iface_detect_lock 628 | def wifi_status_update_auto(self): 629 | return self.wifi_status_update_init() 630 | 631 | @defer.inlineCallbacks 632 | def wifi_status_update_init(self): 633 | 'Send full status update from current NM state.' 634 | self.wifi_conn_res_fail_reason = None 635 | state = yield self.wifi_dev.get('State') 636 | yield self.wifi_status_update_ap() 637 | yield self.iface_changed_apply(self.wifi_dev, state) 638 | 639 | 640 | @defer.inlineCallbacks 641 | def wifi_connect(self, ap_info): 642 | try: ap = self.wifi_aps[ap_info['uid']] 643 | except KeyError: 644 | defer.returnValue(self.wifi_status_update( 645 | 'fail', 'Disconnected', 'Failed to find specified network' )) 646 | 647 | p_prev = self.secrets.get(ap.uuid, 'p') 648 | if isinstance(ap_info, NMAP): 649 | ap_info = dict(uid=ap_info.uid) 650 | ap_info['p'] = p_prev 651 | else: 652 | if not ap_info.get('p'): ap_info['p'] = p_prev 653 | elif ap_info['p'] != p_prev: ap_info['state'] = None 654 | if ap_info['p'] is None: self.secrets.unset(ap.uuid) 655 | else: self.secrets.set(ap.uuid, ap_info, 'p', 'state') 656 | 657 | if 'auto' in ap_info: 658 | self.wifi_update_ap(uid=ap.uid, auto=ap_info['auto']) 659 | 660 | self.log.debug('Initiating AP connection to %s', ap) 661 | 662 | def stop_if_ap_is_gone(): 663 | if self.wifi_dev and ap.uid in self.wifi_aps: return 664 | defer.returnValue(self.wifi_status_update('fail_link', 'Disconnected', ap=ap)) 665 | 666 | ap_obj = self.dbus.ref('AccessPoint', ap.dbus_path) 667 | try: 668 | conn_path = yield self.dbus.nm_settings.GetConnectionByUuid(ap.uuid) 669 | conn_obj = self.dbus.ref('Settings.Connection', conn_path) 670 | settings = yield conn_obj.GetSettings() 671 | except txdbus_error.RemoteError as err: 672 | if err.errName != nm_iface_full('Settings.InvalidConnection'): raise 673 | conn_obj, settings = None, dict() 674 | else: 675 | self.log.debug('Reusing existing connection Settings (path: %s): %s', conn_path, settings) 676 | 677 | # ref-settings.html from NM gtk-doc 678 | settings['connection'] = dict( 679 | id=ap.ssid, uuid=ap.uuid, 680 | type='802-11-wireless', autoconnect=ap_info['auto'] ) 681 | settings['802-11-wireless'] = dict( 682 | ssid=list(txdbus_marshal.Byte(ord(c)) for c in ap.ssid), mode=ap.mode ) 683 | if ap.sec != 'none': 684 | settings['802-11-wireless']['security'] = '802-11-wireless-security' 685 | if ap.sec.startswith('wpa-'): 686 | if len(ap_info['p']) < 8: 687 | defer.returnValue(self.wifi_status_update_fail( 'auth', 688 | action='WPA passphrase/key must be 8-32 characters long', ap=ap )) 689 | if ap.mode == 'adhoc': key_mgmt = 'wpa-none' 690 | elif ap.mode == 'infrastructure': key_mgmt = 'wpa-psk' 691 | else: raise NotImplementedError # XXX: error for e.g. wpa-enterprise 692 | settings['802-11-wireless-security'] = {'key-mgmt': key_mgmt} 693 | elif ap.sec == 'wep': 694 | try: int(ap_info['p'], 16) 695 | except ValueError: pw_hex = False 696 | else: pw_hex = True 697 | pw_len = len(ap_info['p']) 698 | pw_type = 1 if pw_len in [5, 13]\ 699 | or (pw_hex and pw_len in [10, 26]) else 2 700 | settings['802-11-wireless-security'] = { 701 | 'key-mgmt': 'none', 'auth-alg': 'shared', 702 | 'wep-key-flags': 1, 'wep-key-type': pw_type } 703 | else: 704 | settings.pop('802-11-wireless-security', None) 705 | settings['ipv4'] = settings['ipv6'] = dict(method='auto') 706 | 707 | yield stop_if_ap_is_gone() 708 | self.log.debug( 'Activating connection with' 709 | ' settings (update: %s): %s', bool(conn_obj), settings ) 710 | if conn_obj: 711 | yield conn_obj.Update(settings) 712 | active_path = yield self.dbus.nm\ 713 | .ActivateConnection(conn_path, self.wifi_dev_path, ap.dbus_path) 714 | else: 715 | conn_path, active_path = yield self.dbus.nm\ 716 | .AddAndActivateConnection(settings, self.wifi_dev_path, ap.dbus_path) 717 | self.log.debug('Created new Settings object (path: %s)', conn_path) 718 | 719 | d = defer.Deferred() 720 | self.wifi_conn_res_reqs.append(d) 721 | self.wifi_status_update_live('init', ap) 722 | defer.returnValue((yield d)) 723 | 724 | 725 | @defer.inlineCallbacks 726 | def wifi_update_ap_added(self, dev, ap_path): 727 | self.wifi_update_ap_lock(ap_path) 728 | ap = self.dbus.ref('AccessPoint', ap_path) 729 | self.wifi_signals_ap.setdefault(ap.uid, set()).add( 730 | (yield ap.signal('PropertiesChanged', self.wifi_update_ap_changed)) ) 731 | ap_props = dict() 732 | for k in 'Ssid', 'Mode', 'Flags', 'Strength', 'HwAddress', 'MaxBitrate': 733 | ap_props[k] = yield ap.get(k) 734 | ap_props['RsnFlags'] = ap_props['WpaFlags'] = None # should be fetched 735 | ap_params = yield self.wifi_update_ap_changed(ap, ap_props, as_dict=True) 736 | ap_params['dbus_path'] = ap_path 737 | 738 | ap_uuid = NMAP.get_uuid(ap_params['hwaddr']) 739 | try: 740 | conn_path = yield self.dbus.nm_settings.GetConnectionByUuid(ap_uuid) 741 | except txdbus_error.RemoteError as err: 742 | if err.errName != nm_iface_full('Settings.InvalidConnection'): raise 743 | ap_params['auto'] = True 744 | else: 745 | conn_obj = self.dbus.ref('Settings.Connection', conn_path) 746 | settings = yield conn_obj.GetSettings() 747 | ap_params['auto'] = settings['connection'].get('autoconnect', False) 748 | 749 | self.wifi_update_ap(force_new=True, **ap_params) 750 | 751 | @defer.inlineCallbacks 752 | def wifi_update_ap_changed( self, ap, props, as_dict=False, 753 | _as_is=dict(Strength='strength', Mode='mode', HwAddress='hwaddr', MaxBitrate='bitrate') ): 754 | ap_update = dict() 755 | for k, v in props.viewitems(): 756 | if k in ['RsnFlags', 'WpaFlags']: 757 | sec_id = yield ap.get('RsnFlags') 758 | if not sec_id: sec_id = yield ap.get('WpaFlags') 759 | ap_update['sec'] = self.wifi_ap_sec_translate(sec_id) 760 | elif k == 'Ssid': 761 | ap_update['ssid'] = ''.join(map(chr, v)) 762 | elif k == 'Flags': 763 | ap_update['private'] = bool(v & self.wifi_ap_flags['privacy']) 764 | elif k in _as_is: ap_update[_as_is[k]] = v 765 | else: self.log.warn('Unrecognized property in AP update: %r = %r', k, v) 766 | if ap_update.get('private') and ap_update['sec'] == 'none': ap_update['sec'] = 'wep' 767 | if as_dict: defer.returnValue(ap_update) 768 | else: 769 | self.wifi_update_ap(uid=NMAP.get_uid(ap.path), **ap_update) 770 | 771 | @defer.inlineCallbacks 772 | def wifi_update_ap_removed(self, dev, ap_path): 773 | ap_uid = NMAP.get_uid(ap_path) 774 | for sig_ref in self.wifi_signals_ap.get(ap_uid, set()): yield sig_ref.release() 775 | self.wifi_remove_ap(ap_uid) 776 | 777 | 778 | 779 | dbus_iface_nm_ns = 'org.freedesktop.NetworkManager' 780 | dbus_iface_props = 'org.freedesktop.DBus.Properties' 781 | dbus_bus_name_nm = dbus_iface_nm_ns 782 | 783 | def nm_iface_full(iface, fallback=None): 784 | if fallback is not None and iface is None: iface = fallback 785 | if iface is not None and ( 786 | not iface.startswith(dbus_iface_nm_ns) and (not iface or iface[0].isupper()) ): 787 | if iface: iface = '.' + iface 788 | iface = dbus_iface_nm_ns + iface 789 | return iface 790 | 791 | def nm_iface_short(iface): 792 | if iface.startswith(dbus_iface_nm_ns): 793 | iface = iface[len(dbus_iface_nm_ns):].lstrip('.') 794 | return iface 795 | 796 | def dbus_variant_sanitize( data, 797 | bytearrays=True, empty_lists=True ): 798 | '''Detects and gives proper types to things like bytearrays and strips 799 | empty lists, which break marshaling as their type cannot be determined.''' 800 | kws = dict(bytearrays=bytearrays, empty_lists=empty_lists) 801 | if isinstance(data, dict): 802 | data = dict(zip( 803 | dbus_variant_sanitize(data.keys(), bytearrays=False), 804 | dbus_variant_sanitize(data.values(), **kws) )) 805 | if empty_lists: 806 | for k,v in data.items(): 807 | if not (isinstance(v, list) and not v): continue 808 | if empty_lists is True: del data[k] 809 | else: data[k] = type('list_sig', (list,), dict(dbusSignature=empty_lists))() 810 | elif isinstance(data, list): 811 | data = map(ft.partial(dbus_variant_sanitize, **kws), data) 812 | if bytearrays and data\ 813 | and all((isinstance(v, int) and v <= 255) for v in data): 814 | data = map(txdbus_marshal.Byte, data) 815 | return data 816 | 817 | 818 | 819 | class DBusUnknownEnumValue(Exception): pass 820 | 821 | class DBusSignalRef(namedtuple('DBusSignalRef', 'name obj proxy rule_id')): 822 | 823 | def release(self): 824 | return self.proxy.signal_del(self.name, self.obj, self.rule_id) 825 | 826 | class DBusRef(object): 827 | 828 | iface_wrappers = {} 829 | 830 | prop_wrappers = { 831 | 'Device': dict( 832 | DeviceType=dict(enumerate( 'unknown ethernet wifi' 833 | ' unused1 unused2 bt olpc_mesh wimax modem infiniband bond vlan'.split() )), 834 | State={ 835 | 0: 'unknown', 10: 'unmanaged', 20: 'unavailable', 30: 'disconnected', 836 | 40: 'prepare', 50: 'config', 60: 'need_auth', 70: 'ip_config', 80: 'ip_check', 837 | 90: 'secondaries', 100: 'activated', 110: 'deactivating', 120: 'failed' }, 838 | Reason={ 839 | 0: 'unknown', 840 | 1: 'none', 841 | 2: 'now_managed', 842 | 3: 'now_unmanaged', 843 | 4: 'config_failed', 844 | 5: 'config_unavailable', 845 | 6: 'config_expired', 846 | 7: 'no_secrets', 847 | 8: 'supplicant_disconnect', 848 | 9: 'supplicant_config_failed', 849 | 10: 'supplicant_failed', 850 | 11: 'supplicant_timeout', 851 | 12: 'ppp_start_failed', 852 | 13: 'ppp_disconnect', 853 | 14: 'ppp_failed', 854 | 15: 'dhcp_start_failed', 855 | 16: 'dhcp_error', 856 | 17: 'dhcp_failed', 857 | 18: 'shared_start_failed', 858 | 19: 'shared_failed', 859 | 20: 'autoip_start_failed', 860 | 21: 'autoip_error', 861 | 22: 'autoip_failed', 862 | 23: 'modem_busy', 863 | 24: 'modem_no_dial_tone', 864 | 25: 'modem_no_carrier', 865 | 26: 'modem_dial_timeout', 866 | 27: 'modem_dial_failed', 867 | 28: 'modem_init_failed', 868 | 29: 'gsm_apn_failed', 869 | 30: 'gsm_registration_not_searching', 870 | 31: 'gsm_registration_denied', 871 | 32: 'gsm_registration_timeout', 872 | 33: 'gsm_registration_failed', 873 | 34: 'gsm_pin_check_failed', 874 | 35: 'firmware_missing', 875 | 36: 'removed', 876 | 37: 'sleeping', 877 | 38: 'connection_removed', 878 | 39: 'user_requested', 879 | 40: 'carrier', 880 | 41: 'connection_assumed', 881 | 42: 'supplicant_available', 882 | 43: 'modem_not_found', 883 | 44: 'bt_failed', 884 | 45: 'gsm_sim_not_inserted', 885 | 46: 'gsm_sim_pin_required', 886 | 47: 'gsm_sim_puk_required', 887 | 48: 'gsm_sim_wrong', 888 | 49: 'infiniband_mode', 889 | 50: 'dependency_failed', 890 | 51: 'br2684_failed', 891 | 52: 'modem_manager_unavailable', 892 | 53: 'ssid_not_found', 893 | 54: 'secondary_connection_failed' } ), 894 | 'AccessPoint': dict( 895 | Mode={0: 'unknown', 1: 'adhoc', 2: 'infrastructure'} ) 896 | } 897 | 898 | def __init__(self, proxy, iface, path, bus_name=None): 899 | self.iface_short = nm_iface_short(iface) 900 | self.iface_full = nm_iface_full(iface) 901 | self.proxy, self.path, self.bus_name = proxy, path, bus_name 902 | self.log = logging.getLogger('dbus.ref') 903 | 904 | def __hash__(self): 905 | return hash((self.path, self.iface_full)) 906 | 907 | def __repr__(self): 908 | return ''.format(self.iface_short, self.path) 909 | 910 | def interpret_wrapper(self, func): 911 | try: return self.iface_wrappers[self.iface_short][func] 912 | except KeyError: return self.proxy.call 913 | 914 | def interpret_prop_value(self, k, v, strict=False): 915 | if not strict and isinstance(v, types.StringTypes): return v 916 | try: v_dict = self.prop_wrappers[self.iface_short][k] 917 | except KeyError: return v 918 | try: v = v_dict[v] 919 | except KeyError: 920 | if strict: raise DBusUnknownEnumValue(v_dict, v) 921 | if strict or v is not None: 922 | self.log.warn('Unrecognized enum value for %s/%s: %s', self.iface_short, k, v) 923 | v = v_dict.get(None, v_dict.get(0)) # should return something like "unknown" 924 | return v 925 | 926 | def resolve(self, bus_name=None): 927 | assert '//' not in self.path, self 928 | if bus_name is None: bus_name = self.bus_name 929 | if bus_name is None: bus_name = dbus_bus_name_nm 930 | return self.proxy.ref_resolve(self, bus_name) 931 | 932 | def __getattr__(self, func): 933 | return ft.partial(self.interpret_wrapper(func), self, func) 934 | 935 | 936 | def _resolve_iface(func): 937 | @ft.wraps(func) 938 | def _wrapper(self, *args, **kws): 939 | iface = kws.get('iface') 940 | kws['iface'] = nm_iface_full(iface, self.iface_full) 941 | return func(self, *args, **kws) 942 | return _wrapper 943 | 944 | @_resolve_iface 945 | @defer.inlineCallbacks 946 | def get(self, k, iface=None): 947 | v = yield self.proxy.call(self, 'Get', iface, k, _interface=dbus_iface_props) 948 | defer.returnValue(self.interpret_prop_value(k, v)) 949 | 950 | @_resolve_iface 951 | def set(self, k, v, iface=None): 952 | if isinstance(v, bool): v = txdbus_marshal.Boolean(v) 953 | return self.proxy.call(self, 'Set', iface, k, v, _interface=dbus_iface_props) 954 | 955 | @_resolve_iface 956 | @defer.inlineCallbacks 957 | def signal(self, name, callback, iface=None, pass_ref=True): 958 | if pass_ref: 959 | if pass_ref is True: pass_ref = self 960 | callback = ft.partial(callback, self) 961 | wrapper = ft.partial(self._signal_wrapper, self.path, name, callback) 962 | obj = yield self.resolve() 963 | rule_id = yield self.proxy.signal_add(obj, name, wrapper, _interface=iface) 964 | defer.returnValue(DBusSignalRef(name, obj, self.proxy, rule_id)) 965 | 966 | def _signal_wrapper(self, path, name, callback, *args, **kws): 967 | self.log.noise('Signal: %s %s %s %s', path, name, args, kws) 968 | reactor.callLater(0, callback, *args, **kws) 969 | 970 | 971 | class DBusProxy(object): 972 | 973 | dbus_addr = 'system' 974 | dbus_call_defaults = dict( 975 | expectReply=True, autoStart=False, timeout=20, interface=None ) 976 | 977 | nm_ping_interval = 60 978 | nm_wait_limit, nm_wait_retry_delay, nm_wait_retry_factor = 300, 1, 1.2 979 | 980 | def __init__(self, secrets): 981 | self._conn_lock = defer.DeferredLock() # released/unset after connection 982 | self._conn_lock.acquire() 983 | self._secrets = secrets 984 | self._rules, self._refs = dict(), WeakKeyDictionary() 985 | self.log = logging.getLogger('dbus.proxy') 986 | 987 | # Static refs (XXX: nm-specific) 988 | self.nm = self.ref('') 989 | self.nm_settings = self.ref('Settings') 990 | 991 | reactor.callLater(0, self._dbus_connect) 992 | 993 | 994 | @defer.inlineCallbacks 995 | def _dbus_connect(self): 996 | assert self._conn_lock 997 | try: 998 | conn = yield txdbus_client.connect(reactor, self.dbus_addr) 999 | self._conn = conn 1000 | yield self._dbus_exports() 1001 | except: 1002 | self.log.fatal('Exiting due to unrecoverable dbus failure') 1003 | utils.stop() 1004 | raise 1005 | self.log.debug('Connected to DBus instance (address: %r)', self.dbus_addr) 1006 | 1007 | # Make sure NM is running and reachable 1008 | try: 1009 | delay, deadline = self.nm_wait_retry_delay, time.time() + self.nm_wait_limit 1010 | while time.time() < deadline: 1011 | nm_alive = yield self._dbus_test() 1012 | if nm_alive: break 1013 | yield utils.timeout(delay) 1014 | delay *= self.nm_wait_retry_factor 1015 | if not nm_alive: 1016 | raise NMConnectionError('Failed to connect to NetworkManager dbus interface') 1017 | except NMConnectionError as err: 1018 | self.log.fatal('Exiting due to unrecoverable NM connection failure') 1019 | utils.stop() 1020 | raise 1021 | reactor.callLater(0, self._dbus_disconnect_handler) 1022 | 1023 | self._conn_lock.release() 1024 | self._conn_lock = None 1025 | 1026 | @defer.inlineCallbacks 1027 | def _dbus_exports(self): 1028 | agent = DBusSecretAgent(self._secrets) 1029 | yield self._conn.exportObject(agent) 1030 | reactor.callLater(0, lambda: self.ref('AgentManager').Register(nm_secret_agent_guid)) 1031 | 1032 | @defer.inlineCallbacks 1033 | def _dbus_test(self): 1034 | try: 1035 | nm_obj = yield self._ref_resolve(self._conn, self.nm) # doesn't grab _conn_lock 1036 | perms = yield self.call(nm_obj, 'GetPermissions', _interface=dbus_iface_nm_ns) 1037 | if set(perms.viewvalues()).difference(['yes']): 1038 | raise NMConnectionError('Insufficient NM object access permissions') 1039 | except Exception as err: 1040 | if isinstance(err, NMConnectionError): raise 1041 | err_type = err.__class__.__name__ 1042 | self.log.debug('Failed to query NetworkManager dbus interface: (%s) %s', err_type, err) 1043 | else: defer.returnValue(True) 1044 | 1045 | @defer.inlineCallbacks 1046 | def _dbus_disconnect_handler(self, nm_obj=None, reason=None, watchdog=False): 1047 | if nm_obj is None and not watchdog: 1048 | (yield self.nm.resolve()).notifyOnDisconnect(self._dbus_disconnect_handler) 1049 | self._dbus_disconnect_task = task.LoopingCall(self._dbus_disconnect_handler, watchdog=True) 1050 | self._dbus_disconnect_task.start(self.nm_ping_interval, now=False) 1051 | defer.returnValue(None) 1052 | elif watchdog: 1053 | if (yield self._dbus_test()): defer.returnValue(None) 1054 | utils.stop() 1055 | 1056 | def _connected(func): 1057 | @ft.wraps(func) 1058 | @defer.inlineCallbacks 1059 | def _wrapper(self, *args, **kws): 1060 | if self._conn_lock: 1061 | try: yield self._conn_lock.acquire() 1062 | finally: self._conn_lock.release() 1063 | res = yield func(self, self._conn, *args, **kws) 1064 | defer.returnValue(res) 1065 | return _wrapper 1066 | 1067 | 1068 | @_connected 1069 | def ref_resolve(self, conn, ref, bus_name=None): 1070 | return self._ref_resolve(conn, ref, bus_name) 1071 | 1072 | @defer.inlineCallbacks 1073 | def _ref_resolve(self, conn, ref, bus_name=None): 1074 | assert ref.proxy is self, [ref.proxy, self] 1075 | if bus_name is None: bus_name = dbus_bus_name_nm 1076 | try: obj = self._refs[ref][bus_name] 1077 | except KeyError: 1078 | if ref not in self._refs: self._refs[ref] = dict() 1079 | self.log.noise('Resolve: %s %s', bus_name, ref.path) 1080 | obj = yield conn.getRemoteObject(bus_name, ref.path) 1081 | self._refs[ref][bus_name] = obj 1082 | defer.returnValue(obj) 1083 | 1084 | def ref(self, iface, path=None): 1085 | assert '/' not in iface, [iface, path] 1086 | iface = nm_iface_full(iface) 1087 | if path is None: path = '.{}'.format(iface).replace('.', '/') 1088 | return DBusRef(self, iface, path) 1089 | 1090 | 1091 | @defer.inlineCallbacks 1092 | def _process_obj_iface_specs(self, obj_or_ref, kws, defaults=None): 1093 | if '_nm_interface' in kws: 1094 | assert '_interface' not in kws, kws 1095 | kws['_interface'] = nm_iface_full(kws.pop('_nm_interface')) 1096 | if '_interface' not in kws and isinstance(obj_or_ref, DBusRef): 1097 | kws['_interface'] = obj_or_ref.iface_full 1098 | if defaults is None: defaults = kws 1099 | for k, v in defaults.viewitems(): 1100 | k = k.lstrip('_') 1101 | kws[k] = kws.pop('_{}'.format(k), v) 1102 | if isinstance(obj_or_ref, DBusRef): obj_or_ref = yield obj_or_ref.resolve() 1103 | assert isinstance(obj_or_ref, txdbus_objects.RemoteDBusObject), [type(obj_or_ref), obj_or_ref] 1104 | defer.returnValue(obj_or_ref) 1105 | 1106 | @defer.inlineCallbacks 1107 | def call(self, obj_or_ref, func, *args, **kws): 1108 | obj = yield self._process_obj_iface_specs(obj_or_ref, kws, self.dbus_call_defaults) 1109 | self.log.noise( 'Call: %s %s %s %s %s', obj.objectPath, 1110 | map(op.attrgetter('name'), obj.interfaces), func, args, kws ) 1111 | res = yield obj.callRemote(func, *args, **kws) 1112 | defer.returnValue(res) 1113 | 1114 | @defer.inlineCallbacks 1115 | def signal_add(self, obj_or_ref, name, callback, **kws): 1116 | obj = yield self._process_obj_iface_specs(obj_or_ref, kws) 1117 | assert not set(kws.keys()).difference(['interface']), kws 1118 | rule_id = yield obj.notifyOnSignal(name, callback, **kws) 1119 | self.log.noise( 'Signal add: %s %s (iface: %s), rule=%s', 1120 | obj.objectPath, name, kws.get('interface'), rule_id ) 1121 | defer.returnValue(rule_id) 1122 | 1123 | @defer.inlineCallbacks 1124 | def signal_del(self, name, obj, rule_id): 1125 | self.log.noise('Signal del: %s %s, rule=%s', name, obj.objectPath, rule_id) 1126 | yield obj.cancelSignalNotification(rule_id) 1127 | 1128 | 1129 | 1130 | def _dbus_log_calls(func): 1131 | func_name = func.func_name 1132 | if func_name.startswith('dbus_'): func_name = func_name[5:] 1133 | @ft.wraps(func) 1134 | @defer.inlineCallbacks 1135 | def _wrapper(self, *args, **kws): 1136 | call_id = os.urandom(2).encode('hex') 1137 | self.log.noise('Call[%s]: %s %s %s', call_id, func_name, args, kws) 1138 | try: res = yield func(self, *args, **kws) 1139 | except Exception as err: 1140 | err_type = err.__class__.__name__ 1141 | if not isinstance(err, DBusSecretAgentError): 1142 | self.log.exception('Call[%s] unhandled error: (%s) %s', call_id, err_type, err) 1143 | else: self.log.debug('Call[%s] error return: (%s) %s', call_id, err_type, err) 1144 | raise 1145 | self.log.noise('Call[%s] result: %s', call_id, res) 1146 | defer.returnValue(res) 1147 | return _wrapper 1148 | 1149 | 1150 | nm_secret_agent_guid = 'nah.naah.naaaah.SecretAgent' # XXX: some proper product-related guid? 1151 | 1152 | 1153 | class DBusSecretAgentError(Exception): 1154 | 1155 | dbusErrorName = '{}.Error'.format(nm_secret_agent_guid) 1156 | 1157 | 1158 | class DBusSecretAgent(txdbus_objects.DBusObject): 1159 | 1160 | _method = txdbus_iface.Method 1161 | 1162 | dbusInterfaces = [txdbus_iface.DBusInterface( 1163 | nm_iface_full('SecretAgent'), 1164 | _method('GetSecrets', arguments='a{sa{sv}}osasu', returns='a{sa{sv}}'), 1165 | _method('CancelGetSecrets', arguments='os', returns=''), 1166 | _method('SaveSecrets', arguments='a{sa{sv}}o', returns=''), 1167 | _method('DeleteSecrets', arguments='a{sa{sv}}o', returns='') )] 1168 | 1169 | def __init__(self, secrets): 1170 | self.log = logging.getLogger('nm.secrets') 1171 | self.secrets = secrets 1172 | super(DBusSecretAgent, self).__init__( 1173 | '/{}'.format(nm_iface_full('SecretAgent').replace('.', '/')) ) 1174 | 1175 | @_dbus_log_calls 1176 | def dbus_GetSecrets(self, connection, connection_path, setting_name, hints, flags): 1177 | assert setting_name == '802-11-wireless-security', setting_name 1178 | ap_uuid = connection['connection']['uuid'] 1179 | pw = self.secrets.get(ap_uuid).get('p') 1180 | if connection['802-11-wireless-security'].get('key-mgmt') != 'none': # wpa 1181 | pw_prev = connection['802-11-wireless-security'].get('psk') 1182 | if flags & 0x02 and pw == pw_prev: # prev auth with stored key has failed 1183 | self.secrets.update(ap_uuid, state='error') # redundant with no_secrets nm-state-signal 1184 | pw = None 1185 | else: sec = {'psk': pw} 1186 | else: # wep 1187 | # WEP connections don't re-request key and fail with reason=ssid_not_found 1188 | sec = dict(('wep-key{}'.format(n), pw) for n in xrange(4)) 1189 | if not pw: raise DBusSecretAgentError('No secrets available') 1190 | return {'802-11-wireless-security': sec} 1191 | 1192 | @_dbus_log_calls 1193 | def dbus_SaveSecrets(self, connection, connection_path): pass 1194 | @_dbus_log_calls 1195 | def dbus_CancelGetSecrets(self, connection_path, setting_name): pass 1196 | @_dbus_log_calls 1197 | def dbus_DeleteSecrets(self, connection, connection_path): pass 1198 | -------------------------------------------------------------------------------- /nm_wifi_webui/secrets.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from twisted.python.filepath import FilePath 6 | 7 | import itertools as it, operator as op, functools as ft 8 | from tempfile import NamedTemporaryFile 9 | import os, sys 10 | 11 | import bencode 12 | bencode.encode_func[type(None)] = lambda x,r: r.append('n') 13 | bencode.decode_func['n'] = lambda x,f: (None, f+1) 14 | 15 | 16 | class SecretStorage(object): 17 | 18 | def __init__(self, path): 19 | if not isinstance(path, FilePath): path = FilePath(path) 20 | self.path, self.cache, self.cache_src = path, dict(), None 21 | self.load() 22 | 23 | def load(self): 24 | if self.path.exists(): 25 | with self.path.open() as src: 26 | self.cache_src = src.read() 27 | self.cache = bencode.bdecode(self.cache_src) 28 | 29 | def dump(self): 30 | cache_dst = bencode.bencode(self.cache) 31 | if self.cache_src == cache_dst: return # no changes 32 | tmp_path = self.path.temporarySibling() 33 | try: 34 | with tmp_path.open('w') as tmp: 35 | os.fchmod(tmp.fileno(), 0600) 36 | tmp.write(cache_dst) 37 | tmp.flush() 38 | os.rename(tmp_path.path, self.path.path) 39 | finally: 40 | try: tmp_path.remove() 41 | except (OSError, IOError): pass 42 | self.cache_src = cache_dst 43 | 44 | def get(self, uuid, key=None): 45 | val = self.cache.get(uuid) 46 | if val and key is not None: val = val.get(key) 47 | return val 48 | 49 | def set(self, uuid, secret, *keys, **keymap): 50 | assert isinstance(secret, dict), secret 51 | if uuid not in self.cache: self.cache[uuid] = dict() 52 | if keymap: keys = list(keys) + keymap.items() 53 | if keys: 54 | for k in keys: 55 | k1, k2 = (k, k) if not isinstance(k, tuple) else k 56 | if k2 in secret: self.cache[uuid][k1] = secret[k2] 57 | else: 58 | self.cache[uuid].update(secret) 59 | self.dump() 60 | 61 | def update(self, uuid, **data): 62 | if uuid not in self.cache: self.cache[uuid] = dict() 63 | self.cache[uuid].update(data) 64 | self.dump() 65 | 66 | def remove(self, uuid, *keys): 67 | for k in keys: del self.cache[uuid][k] 68 | self.dump() 69 | 70 | def unset(self, uuid, _obj=object()): 71 | if self.cache.pop(uuid, _obj) is _obj: return 72 | self.dump() 73 | -------------------------------------------------------------------------------- /nm_wifi_webui/utils.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from twisted.internet import reactor, defer, error 6 | from twisted.python import log as twisted_log 7 | from twisted.python.filepath import FilePath 8 | 9 | from threading import Event 10 | import os, sys, types, logging 11 | 12 | try: from colorlog import ColoredFormatter 13 | except ImportError: ColoredFormatter = None 14 | 15 | log = logging.getLogger('utils.core') 16 | 17 | 18 | exit_code = 0 19 | 20 | def stop(code=1): 21 | global exit_code 22 | if code is not None: exit_code = code 23 | try: reactor.stop() 24 | except error.ReactorNotRunning: pass 25 | 26 | def die(tb=False): 27 | import signal 28 | if tb: 29 | import traceback 30 | print(' -------- abort -------- ', file=sys.stderr) 31 | traceback.print_stack(file=sys.stderr) 32 | os.kill(0, signal.SIGABRT) 33 | 34 | 35 | def force_bytes(bytes_or_unicode, encoding='utf-8', errors='backslashreplace'): 36 | if isinstance(bytes_or_unicode, bytes): return bytes_or_unicode 37 | return bytes_or_unicode.encode(encoding, errors) 38 | 39 | def force_unicode(bytes_or_unicode, encoding='utf-8', errors='replace'): 40 | if isinstance(bytes_or_unicode, unicode): return bytes_or_unicode 41 | return bytes_or_unicode.decode(encoding, errors) 42 | 43 | def to_bytes(obj, **conv_kws): 44 | if not isinstance(obj, types.StringTypes): obj = bytes(obj) 45 | return force_bytes(obj) 46 | 47 | 48 | if hasattr(twisted_log, 'NewSTDLibLogObserver'): 49 | SmartPythonLogObserver = twisted_log.PythonLoggingObserver 50 | else: # hack for older twisted 51 | class SmartPythonLogObserver(twisted_log.PythonLoggingObserver): 52 | '''PythonLoggingObserver that passes all the attributes from twisted 53 | eventDict to python logging subsystem, prefixing each key with "ev_".''' 54 | def emit(self, eventDict): 55 | if 'logLevel' in eventDict: level = eventDict['logLevel'] 56 | elif eventDict['isError']: level = logging.ERROR 57 | else: level = logging.INFO 58 | text = twisted_log.textFromEventDict(eventDict) 59 | if text is None: return 60 | extra = dict(('ev_{}'.format(k), v) for k,v in eventDict.viewitems()) 61 | extra['MESSAGE_ID'] = twisted_msg_id 62 | self.logger.log(level, text, extra=extra) 63 | 64 | def init_logging( handler=None, debug=False, noise=False, 65 | one_logger=None, twisted_logger='twisted', _done=Event() ): 66 | assert not _done.is_set(), 'Should only be called once' 67 | 68 | if isinstance(twisted_logger, types.StringTypes): 69 | twisted_logger = logging.getLogger(twisted_logger) 70 | 71 | noise_level = logging.NOISE = max(1, logging.DEBUG - 5) 72 | logging.addLevelName(logging.NOISE, 'NOISE') 73 | def log_noise(self, msg, *args, **kwargs): 74 | if self.isEnabledFor(noise_level): 75 | self._log(noise_level, msg, args, **kwargs) 76 | logging.Logger.noise = log_noise 77 | 78 | logging.root.setLevel(0) 79 | twisted_log.defaultObserver.stop() 80 | 81 | formatter = logging.Formatter 82 | if handler is None: 83 | stream = sys.stderr 84 | handler = logging.StreamHandler(stream) 85 | if ColoredFormatter and getattr(stream, 'isatty', lambda: False)(): 86 | def formatter(fmt, *args, **kws): 87 | assert 'log_colors' not in kws, kws 88 | kws['log_colors'] = dict( NOISE='white', DEBUG='white', 89 | INFO='green', WARNING='yellow', ERROR='bold_red', CRITICAL='bold_red' ) 90 | return ColoredFormatter('%(log_color)s'+fmt, *args, **kws) 91 | 92 | handler.setFormatter(formatter( 93 | '%(asctime)s :: %(name)s %(levelname)s :: %(message)s', 94 | '%Y-%m-%d %H:%M:%S' )) 95 | if noise: level = logging.NOISE 96 | elif debug: level = logging.DEBUG 97 | else: level = logging.WARNING 98 | handler.setLevel(level) 99 | 100 | if not one_logger: 101 | logging.root.addHandler(handler) 102 | else: 103 | logging.root.addHandler(logging.NullHandler()) 104 | if isinstance(one_logger, types.StringTypes): 105 | one_logger = logging.getLogger(one_logger) 106 | one_logger.setLevel(0) 107 | one_logger.addHandler(handler) 108 | twisted_logger.setLevel(logging.WARNING) 109 | twisted_logger.addHandler(handler) 110 | 111 | log_observer = SmartPythonLogObserver(loggerName=twisted_logger.name) 112 | log_observer.start() 113 | _done.set() 114 | 115 | 116 | def build_url(host, port=None, scheme=None, path=''): 117 | if port is not None: 118 | port = int(port) 119 | if scheme is None: 120 | scheme = 'https' if port in [443, 8443] else 'http' 121 | if scheme == 'http' and port == 80: port = None 122 | elif scheme == 'https' and port == 443: port = None 123 | else: assert port > 0, port 124 | port = ':{}'.format(port) 125 | else: scheme, port = 'http', '' 126 | return '{}://{}{}/{}'.format(scheme, host, port, path) 127 | 128 | 129 | @defer.inlineCallbacks 130 | def first_result(*deferreds): 131 | try: 132 | res, idx = yield defer.DeferredList( 133 | deferreds, fireOnOneCallback=True, fireOnOneErrback=True ) 134 | except defer.FirstError as err: err.subFailure.raiseException() 135 | defer.returnValue(res) 136 | 137 | def timeout(delay, for_deferred=None, result=None): 138 | '''Returns deferred that gets callback with specified result (default: None) 139 | when delay expires or for_deferred fires (if passed, whichever first).''' 140 | d = defer.Deferred(canceller=lambda d: timer.cancel()) 141 | timer = reactor.callLater(delay, d.callback, result) 142 | if for_deferred: return first_result(d, for_deferred) 143 | return d 144 | 145 | 146 | @defer.inlineCallbacks 147 | def log_errors(d): 148 | try: yield d 149 | except Exception as e: 150 | log.exception('Unhandled error: <%s> %s', type(e), e) 151 | raise 152 | -------------------------------------------------------------------------------- /nm_wifi_webui/webui.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from nm_wifi_webui import utils 6 | 7 | from txsockjs.factory import SockJSResource 8 | import jinja2 9 | 10 | from twisted.internet import reactor, defer, task, protocol 11 | from twisted.python.filepath import FilePath 12 | from twisted.web.static import File 13 | from twisted.web.client import urlunparse 14 | from twisted.web.util import ParentRedirect 15 | from twisted.web import resource, server, http 16 | 17 | import itertools as it, operator as op, functools as ft 18 | import logging, json 19 | 20 | 21 | class EventProtocol(protocol.Protocol): 22 | 23 | def __init__(self): 24 | self.log = logging.getLogger('webui.event.protocol') 25 | self.log_peer = None 26 | 27 | def connectionMade(self): 28 | self.log_peer = str(self.transport.getPeer()) 29 | self.log.debug('Connection to client: %s', self.log_peer) 30 | self.factory.clients.add(self) 31 | 32 | def dataReceived(self, data): 33 | self.log.noise('Data from client %s: %r', self.log_peer, data) 34 | self.factory.handle(data) 35 | 36 | def dataSend(self, data): 37 | self.log.noise('Sending to client %s: %r', self.log_peer, data) 38 | self.transport.write(data) 39 | 40 | def connectionLost(self, reason): 41 | self.log.debug('Lost connection to client %s: %s', self.log_peer, reason) 42 | self.factory.clients.remove(self) 43 | 44 | 45 | class ClientEvents(protocol.Factory): 46 | 47 | protocol = EventProtocol 48 | 49 | def __init__(self, handler): 50 | self.log = logging.getLogger('webui.event.factory') 51 | self.clients, self.handler = set(), handler 52 | 53 | def handle(self, data): 54 | try: ev = json.loads(data) 55 | except ValueError: 56 | self.log.debug('Invalid event data, ignoring: %r', data) 57 | return 58 | return self.handler(ev) 59 | 60 | def send(self, ev): 61 | data = json.dumps(ev) 62 | for client in self.clients: client.dataSend(data) 63 | 64 | 65 | class WebUIAction(ParentRedirect): 66 | 67 | isLeaf = True 68 | 69 | def __init__(self, webui, action, *action_args, **action_kws): 70 | resource.Resource.__init__(self) 71 | self.log = logging.getLogger('webui.actions') 72 | self.webui = webui 73 | self.action = ft.partial(getattr(self, action), *action_args, **action_kws) 74 | 75 | def nm_online(self): 76 | reactor.callLater(0, self.webui.nm.wifi_conn_enabled_set, True) 77 | 78 | def nm_offline(self): 79 | reactor.callLater(0, self.webui.nm.wifi_conn_enabled_set, False) 80 | 81 | def nm_scan(self): 82 | reactor.callLater(0, self.webui.nm.wifi_scan) 83 | 84 | def nm_disconnect(self, ap): 85 | self.webui.nm.wifi_disconnect(ap, action=True) 86 | 87 | def render(self, request): 88 | self.action() 89 | return ParentRedirect.render(self, request) 90 | 91 | 92 | class WebUI(resource.Resource): 93 | 94 | nm = None # set externally 95 | url_events = 'events' 96 | post_nm_wait_max = 20 97 | 98 | def __init__(self, static_path, templates_path): 99 | resource.Resource.__init__(self) 100 | self.log = logging.getLogger('webui.core') 101 | 102 | for action in 'online', 'offline', 'scan': 103 | self.putChild(action, WebUIAction(self, 'nm_{}'.format(action))) 104 | 105 | if not isinstance(static_path, FilePath): 106 | static_path = FilePath(static_path) 107 | for p in static_path.listdir(): 108 | self.putChild(p, File(static_path.child(p).path)) 109 | 110 | if not isinstance(templates_path, FilePath): 111 | templates_path = FilePath(templates_path) 112 | self.templates = jinja2.Environment( 113 | loader=jinja2.FileSystemLoader(templates_path.path) ) 114 | self.templates.filters['json'] = self.jinja2_json 115 | self.templates.filters['unless_false'] = self.jinja2_unless_false 116 | 117 | self.events = ClientEvents(self.handle_command) 118 | self.putChild('events', SockJSResource(self.events)) 119 | 120 | 121 | def jinja2_json(self, val): 122 | return json.dumps(val) 123 | 124 | def jinja2_unless_false(self, res, val): 125 | return res if not val is False else '' 126 | 127 | 128 | def dispatch(self, ev): 129 | assert ev['q'] in ['online', 'new', 'update', 'remove', 'result', 'status'], ev 130 | assert (ev['q'] not in ['result', 'status'] and ev.get('ap'))\ 131 | or (ev['q'] != 'result' or ev['id']), ev 132 | assert ev['q'] != 'status' or (ev.get('status') and ev.get('action')), ev 133 | self.events.send(ev) 134 | 135 | def dispatch_status(self, status=None, action=None, code=None, ap_uid=None, config=None): 136 | if not (status or action or config): 137 | code, ap = self.nm.wifi_conn_res_code, self.nm.wifi_conn_res_ap 138 | status, action = self.nm.wifi_conn_res_last, self.nm.wifi_conn_res_act 139 | config = self.nm.wifi_conn_res_config 140 | ap_uid = ap and ap.uid 141 | config = self.get_config_params(config) if config else dict() 142 | self.dispatch(dict( q='status', 143 | status=status, action=action, code=code, ap_uid=ap_uid, config=config )) 144 | 145 | def dispatch_online(self, val=None): 146 | if val is None: val = self.nm.wifi_conn_enabled 147 | self.dispatch(dict(q='online', value=val)) 148 | 149 | 150 | @defer.inlineCallbacks 151 | def handle_command(self, ev): 152 | assert ev['q'] in ['online', 'connect', 'disconnect', 'scan', 'sync', 'auto'] and ev['id'], ev 153 | dispatch = lambda r,**kw: self.dispatch(dict(q='result', id=ev['id'], r=r, **kw)) 154 | 155 | if ev['q'] == 'online': 156 | yield self.nm.wifi_conn_enabled_set(bool(ev['value']), action=True) 157 | dispatch('done') 158 | 159 | if ev['q'] == 'disconnect': 160 | try: yield self.nm.wifi_disconnect(action=True) 161 | except self.nm.NMActionError: pass 162 | dispatch('done') 163 | 164 | elif ev['q'] == 'connect': 165 | form = dict() 166 | for v in ev['form']: 167 | form.setdefault(v['name'], list()).append(v['value']) 168 | form = self.ap_info_args(form) 169 | try: conn_res = yield self.nm.wifi_connect(form) 170 | except Exception as err: 171 | self.log.error('NM connection attempt failed (%s)', err) 172 | dispatch('fail') 173 | else: 174 | assert conn_res in self.nm.wifi_conn_res, conn_res 175 | dispatch(conn_res) 176 | 177 | elif ev['q'] == 'scan': 178 | yield self.nm.wifi_scan() 179 | dispatch('done', aps=self.get_ap_data()) 180 | 181 | elif ev['q'] == 'sync': 182 | self.dispatch_online() 183 | for ap in (yield self.nm.wifi_aps_list()): 184 | self.dispatch(dict(q='new', ap=self.ap_info(ap))) 185 | self.dispatch_status() 186 | dispatch('done', aps=self.get_ap_data()) 187 | 188 | elif ev['q'] == 'auto': 189 | self.nm.wifi_update_ap_auto(ev['ap_uid'], ev['value'], action=True) 190 | dispatch('done') 191 | 192 | def handle_ap_update(self, ev, ap): 193 | assert ev in ['new', 'update', 'remove'], ev 194 | self.dispatch(dict(q=ev, ap=self.ap_info(ap))) 195 | 196 | def handle_status_update(self, status, action, code, ap, config): 197 | self.dispatch_status( status=status, action=action, 198 | code=code, ap_uid=ap and ap.uid, config=config ) 199 | 200 | def handle_online_update(self, val=None): 201 | self.dispatch_online(val) 202 | 203 | 204 | def ap_info(self, ap): 205 | ap = ap.data 206 | ap['sec'] = ap['sec'].upper() 207 | ap['title'] = '\n'.join([ 'SSID: {0[ssid]}', 208 | 'Mode: {0[mode]}', '{sec}', 209 | 'Signal strength: {0[strength]}%', 210 | 'Bitrate: {rate:.1f} Mbps', 'BSSID: {0[hwaddr]}' ])\ 211 | .format( ap, rate=ap['bitrate'] / 1000.0, 212 | sec='Open Access Point' if not ap['private'] else 'Security: {}'.format(ap['sec']) ) 213 | assert ap['pass_state'] in ['success', 'error', None], ap['pass_state'] 214 | return ap 215 | 216 | def ap_info_args(self, args): 217 | ap = dict() 218 | for k in 'uid', 'p': 219 | v, = args[k] 220 | if isinstance(v, unicode): v = v.encode('utf-8') 221 | ap[k] = v 222 | ap['auto'] = bool(args.get('auto', False)) 223 | for k in 'connect', 'disconnect': ap[k] = bool(args.get(k)) 224 | return ap 225 | 226 | def get_ap_data(self): 227 | return map( self.ap_info, 228 | sorted(self.nm.wifi_aps.viewvalues(), key=op.attrgetter('uid')) ) 229 | 230 | def get_config_params(self, config): 231 | addrs = list() 232 | for addr, prefix, gw in it.chain(config['ipv4_addrs'], config['ipv6_addrs']): 233 | gw = '' if not gw else ' (gw: {})'.format(gw) 234 | addrs.append('{}/{}{}'.format(addr, prefix, gw)) 235 | if not addrs: return 'not configured' 236 | data = ['Address{}: {}'.format('es' if len(addrs) > 1 else '', ', '.join(sorted(addrs)))] 237 | nameservers = config.get('nameservers') 238 | if nameservers: 239 | data.append('Nameserver{}: {}'.format( 240 | 's' if len(nameservers) > 1 else '', ', '.join(sorted(nameservers)) )) 241 | return '
'.join(data) 242 | 243 | 244 | def _render_headers(self, request, ct='text/html; charset=UTF-8'): 245 | request.setHeader('Expires', '-1') 246 | request.setHeader('Cache-Control', 'private, max-age=0') 247 | request.setHeader('X-UA-Compatible', 'IE=edge,chrome=1') 248 | request.setHeader('Content-Type', ct) 249 | 250 | 251 | def render_GET(self, request): 252 | sock = request.getHost() 253 | nm_code = self.nm.wifi_conn_res_code 254 | nm_connection = not ( 255 | nm_code.startswith('idle_') or nm_code.startswith('fail_') ) 256 | nm_config = self.nm.wifi_conn_res_config 257 | if nm_config: nm_config = self.get_config_params(nm_config) 258 | env = dict( 259 | ap_data=self.get_ap_data(), 260 | nm_enabled=self.nm.wifi_conn_enabled, 261 | nm_status=self.nm.wifi_conn_res_last, 262 | nm_action=self.nm.wifi_conn_res_act, 263 | nm_ap=self.nm.wifi_conn_res_ap, 264 | nm_code=nm_code, 265 | nm_config=nm_config, 266 | nm_connection=nm_connection, 267 | events_url=utils.build_url( 268 | sock.host, sock.port, path=self.url_events ) ) 269 | self._render_headers(request) 270 | return self.templates.get_template('index.html').render(**env).encode('utf-8') 271 | 272 | 273 | def render_POST(self, request): 274 | 'Used only for non-SockJS requests (i.e. no with JS enabled/supported).' 275 | try: 276 | ap = self.ap_info_args(request.args) 277 | if not (ap.get('connect') or ap.get('disconnect')): raise KeyError(ap) 278 | except KeyError: 279 | self.log.debug('Invalid form data, args: %s, ap: %s', request.args, ap) 280 | request.setResponseCode(http.BAD_REQUEST) 281 | return 'Invalid form data' 282 | if ap.get('connect'): 283 | reactor.callLater(0, self.render_nm_connection_result, request, ap) 284 | elif ap.get('disconnect'): 285 | reactor.callLater(0, self.render_nm_disconnect, request) 286 | return server.NOT_DONE_YET 287 | 288 | @defer.inlineCallbacks 289 | def render_nm_connection_result(self, request, ap): 290 | try: 291 | res = yield utils.timeout( 292 | self.post_nm_wait_max, self.nm.wifi_connect(ap) ) 293 | except: 294 | self.log.exception('NM connection attempt failed') 295 | res = False 296 | if request._disconnected: defer.returnValue(None) 297 | dst_url = request.prePathURL() 298 | if res is None: 299 | self._render_headers(request) 300 | request.setResponseCode(http.OK) 301 | request.write( self.templates\ 302 | .get_template('static_nm_redirect.html')\ 303 | .render(url_base=dst_url).encode('utf-8') ) 304 | else: 305 | request.redirect(dst_url) 306 | request.finish() 307 | 308 | @defer.inlineCallbacks 309 | def render_nm_disconnect(self, request): 310 | try: yield self.nm.wifi_disconnect(action=True) 311 | except self.nm.NMActionError: pass 312 | request.redirect(request.prePathURL()) 313 | request.finish() 314 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bencode==1.0 2 | Jinja2==2.8 3 | MarkupSafe==0.23 4 | Twisted==16.3.0 5 | txdbus==1.0.13 6 | txsockjs==1.2.2 7 | zope.interface==4.2.0 8 | -------------------------------------------------------------------------------- /static/css/bootstrap-switch.min.css: -------------------------------------------------------------------------------- 1 | /* ======================================================================== 2 | * bootstrap-switch - v3.0.0 3 | * http://www.bootstrap-switch.org 4 | * ======================================================================== 5 | * Copyright 2012-2013 Mattia Larentis 6 | * 7 | * ======================================================================== 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * ======================================================================== 20 | */ 21 | 22 | .bootstrap-switch{display:inline-block;cursor:pointer;border-radius:4px;border:1px solid;border-color:#ccc;position:relative;text-align:left;overflow:hidden;line-height:8px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;vertical-align:middle;min-width:100px;-webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.bootstrap-switch.bootstrap-switch-mini{min-width:71px}.bootstrap-switch.bootstrap-switch-mini>div>span,.bootstrap-switch.bootstrap-switch-mini>div>label{padding-bottom:4px;padding-top:4px;font-size:10px;line-height:9px}.bootstrap-switch.bootstrap-switch-mini .bootstrap-switch-mini-icons{height:1.2em;line-height:9px;vertical-align:text-top;text-align:center;transform:scale(.6);margin-top:-1px;margin-bottom:-1px}.bootstrap-switch.bootstrap-switch-small{min-width:79px}.bootstrap-switch.bootstrap-switch-small>div>span,.bootstrap-switch.bootstrap-switch-small>div>label{padding-bottom:3px;padding-top:3px;font-size:12px;line-height:18px}.bootstrap-switch.bootstrap-switch-large{min-width:120px}.bootstrap-switch.bootstrap-switch-large>div>span,.bootstrap-switch.bootstrap-switch-large>div>label{padding-bottom:9px;padding-top:9px;font-size:16px;line-height:normal}.bootstrap-switch.bootstrap-switch-animate>div{-webkit-transition:margin-left .5s;transition:margin-left .5s}.bootstrap-switch.bootstrap-switch-on>div{margin-left:0}.bootstrap-switch.bootstrap-switch-on>div>label{border-bottom-right-radius:3px;border-top-right-radius:3px}.bootstrap-switch.bootstrap-switch-off>div{margin-left:-50%}.bootstrap-switch.bootstrap-switch-off>div>label{border-bottom-left-radius:3px;border-top-left-radius:3px}.bootstrap-switch.bootstrap-switch-disabled,.bootstrap-switch.bootstrap-switch-readonly{opacity:.5;filter:alpha(opacity=50);cursor:default!important}.bootstrap-switch.bootstrap-switch-disabled>div>span,.bootstrap-switch.bootstrap-switch-readonly>div>span,.bootstrap-switch.bootstrap-switch-disabled>div>label,.bootstrap-switch.bootstrap-switch-readonly>div>label{cursor:default!important}.bootstrap-switch.bootstrap-switch-focused{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.bootstrap-switch>div{display:inline-block;width:150%;top:0;border-radius:4px;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.bootstrap-switch>div>span,.bootstrap-switch>div>label{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;cursor:pointer;display:inline-block!important;height:100%;padding-bottom:4px;padding-top:4px;font-size:14px;line-height:20px}.bootstrap-switch>div>span{text-align:center;z-index:1;width:33.33333333%}.bootstrap-switch>div>span.bootstrap-switch-handle-on{color:red;border-bottom-left-radius:3px;border-top-left-radius:3px}.bootstrap-switch>div>span.bootstrap-switch-handle-off{color:#000;background:#eee;border-bottom-right-radius:3px;border-top-right-radius:3px}.bootstrap-switch>div>span.bootstrap-switch-primary{color:#fff;background:#428bca}.bootstrap-switch>div>span.bootstrap-switch-info{color:#fff;background:#5bc0de}.bootstrap-switch>div>span.bootstrap-switch-success{color:#fff;background:#5cb85c}.bootstrap-switch>div>span.bootstrap-switch-warning{background:#f0ad4e;color:#fff}.bootstrap-switch>div>span.bootstrap-switch-danger{color:#fff;background:#d9534f}.bootstrap-switch>div>span.bootstrap-switch-default{color:#000;background:#eee}.bootstrap-switch>div>label{text-align:center;margin-top:-1px;margin-bottom:-1px;z-index:100;width:33.33333333%;color:#333;background:#fff}.bootstrap-switch input[type=radio],.bootstrap-switch input[type=checkbox]{position:absolute!important;top:0;left:0;opacity:0;filter:alpha(opacity=0);z-index:-1} -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | label.sb-label { 2 | font-weight: normal; } 3 | 4 | .container { 5 | padding: 1em 0; } 6 | 7 | #sb-scan { 8 | display: none; } 9 | 10 | #sb-ap-list { 11 | margin: 2em 0; } 12 | #sb-ap-list li { 13 | margin: 1em; } 14 | 15 | .sb-ap-btn-main { 16 | padding-right: 55px; 17 | padding-left: 1em; 18 | text-align: left; } 19 | 20 | .sb-ap-indicators { 21 | position: absolute; 22 | right: 55px; 23 | width: 55px; } 24 | 25 | .sb-ap-indicators, .sb-ap-indicators span { 26 | display: inline-block; } 27 | 28 | .sb-ap-private { 29 | position: relative; 30 | top: -1px; } 31 | 32 | .sb-ap-bar { 33 | width: 50px; 34 | padding: 1px; 35 | border: 1px solid black; 36 | height: 20px; 37 | text-align: left; } 38 | 39 | .sb-ap-bar-inner { 40 | width: 0; 41 | background: black; 42 | height: 16px; 43 | margin: 0; } 44 | 45 | .btn-success .sb-ap-bar, .btn-info .sb-ap-bar, .btn-danger .sb-ap-bar { 46 | border-color: white; } 47 | .btn-success .sb-ap-bar-inner, .btn-info .sb-ap-bar-inner, .btn-danger .sb-ap-bar-inner { 48 | background-color: white; } 49 | 50 | .sb-ap-form-auto-box { 51 | margin-top: 0.5em; 52 | margin-left: 0.5em; } 53 | .sb-ap-form-auto-box .sb-ap-form-auto { 54 | position: relative; 55 | top: 0.2em; } 56 | 57 | -------------------------------------------------------------------------------- /static/css/main.scss: -------------------------------------------------------------------------------- 1 | label.sb-label { font-weight: normal; } 2 | 3 | .container { padding: 1em 0; } 4 | 5 | #sb-scan { display: none; } 6 | 7 | #sb-ap-list { 8 | margin: 2em 0; 9 | li { margin: 1em; } 10 | } 11 | 12 | .sb-ap-btn-main { 13 | padding: { right: 55px; left: 1em; } 14 | text-align: left; 15 | } 16 | 17 | .sb-ap-indicators { 18 | position: absolute; 19 | right: 55px; 20 | width: 55px; 21 | } 22 | .sb-ap-indicators, 23 | .sb-ap-indicators span { display: inline-block; } 24 | 25 | .sb-ap-private { 26 | position: relative; 27 | top: -1px; 28 | } 29 | 30 | .sb-ap-bar { 31 | width: 50px; 32 | padding: 1px; 33 | border: 1px solid black; 34 | height: 20px; 35 | text-align: left; 36 | } 37 | .sb-ap-bar-inner { 38 | width: 0; 39 | background: black; 40 | height: 16px; 41 | margin: 0; 42 | } 43 | .btn-success, .btn-info, .btn-danger { 44 | .sb-ap-bar { border-color: white; } 45 | .sb-ap-bar-inner { background-color: white; } 46 | } 47 | 48 | .sb-ap-form-auto-box { 49 | margin: { top: .5em; left: .5em; } 50 | .sb-ap-form-auto { 51 | position: relative; 52 | top: .2em; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /static/css/noscript.css: -------------------------------------------------------------------------------- 1 | #sb-ap-list .dropdown-menu { 2 | display: block; 3 | position: static; } 4 | 5 | #sb-conn-mode-box { 6 | display: none; } 7 | 8 | #sb-ap-list > li { 9 | margin: 3em 1em; } 10 | #sb-ap-list > li .sb-ap-btn-main { 11 | color: white; 12 | background-color: #3276b1; 13 | border-color: #285e8e; } 14 | #sb-ap-list > li .sb-ap-btn-main.btn-success { 15 | background-color: #47a447; 16 | border-color: #398439; } 17 | 18 | .sb-ap-bar { 19 | border-color: white; } 20 | 21 | .sb-ap-bar-inner { 22 | background-color: white; } 23 | 24 | -------------------------------------------------------------------------------- /static/css/noscript.scss: -------------------------------------------------------------------------------- 1 | #sb-ap-list .dropdown-menu { display: block; position: static; } 2 | 3 | #sb-conn-mode-box { display: none; } 4 | 5 | #sb-ap-list > li { 6 | margin: 3em 1em; 7 | .sb-ap-btn-main { 8 | color: white; 9 | background-color: rgb(50, 118, 177); 10 | border-color: rgb(40, 94, 142); 11 | &.btn-success { 12 | background-color: rgb(71, 164, 71); 13 | border-color: rgb(57, 132, 57); 14 | } 15 | } 16 | } 17 | 18 | .sb-ap-bar { border-color: white; } 19 | .sb-ap-bar-inner { background-color: white; } 20 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/static/favicon.ico -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-fg/NetworkManager-WiFi-WebUI/475d2fcc6b2634cfb4bdf47a41fc38a495c0178a/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/js/main.coffee: -------------------------------------------------------------------------------- 1 | 2 | assert = (condition, message) -> 3 | if not condition then throw message or 'Assertion failed' 4 | 5 | 6 | ## Make sure we have all we need before going dynamic 7 | 8 | assert JSON and $ and SockJS 9 | 10 | data_node = $('script[src="js/main.js"]') 11 | assert data_node.get(0) 12 | 13 | conn_box = $('#sb-conn-box') 14 | conn_status = conn_box.find('#sb-conn-status') 15 | conn_action = conn_box.find('#sb-conn-action') 16 | conn_config = conn_box.find('#sb-conn-config') 17 | conn_mode = $('#sb-conn-mode') 18 | conn_mode_lock = false 19 | 20 | ap_list = $('#sb-ap-list') 21 | ap_list_empty = ap_list.find('.sb-ap-list-empty') 22 | ap_tpl = ap_list.find('.sb-ap.sb-ap-tpl') 23 | assert ap_list_empty and ap_tpl and conn_box 24 | 25 | # console.log(data_node.data('aps')) 26 | 27 | 28 | $(document).ready -> 29 | 30 | ## Websockets base 31 | 32 | sock_url = data_node.data('events-url') 33 | console.log('SockJS url:', sock_url) 34 | assert sock_url 35 | 36 | sock_evid = 0 37 | sock_evid_get = -> 38 | sock_evid += 1 39 | sock_evid_handlers = {} 40 | 41 | sock = sock_connect_timer = null 42 | 43 | sock_send = (q, data) -> 44 | assert q? 45 | data = {} if not data? 46 | data.q = q 47 | data.id = sock_evid_get() 48 | # console.log('traffic >>', data) 49 | sock.send(JSON.stringify(data)) 50 | data.id 51 | 52 | sock_connect = -> 53 | sock = new SockJS(sock_url, null, transports: 'xhr-streaming') 54 | sock.onopen = sock_onopen 55 | sock.onclose = sock_onclose 56 | sock.onmessage = sock_onmessage 57 | 58 | sock_onopen = -> 59 | console.log('connected to:', sock_url) 60 | if sock_connect_timer 61 | clearInterval(sock_connect_timer) 62 | ap_list.find('.sb-ap').not(ap_tpl).remove() 63 | sock_send('sync') 64 | sock_connect_timer = null 65 | 66 | sock_onclose = -> 67 | console.log('disconnected from:', sock_url) 68 | if not sock_connect_timer 69 | sock_connect_timer = window.setInterval((-> sock_connect()), 2000) 70 | 71 | sock_onmessage = (e) -> 72 | data = $.parseJSON(e.data) 73 | # console.log('traffic <<', data) 74 | 75 | if data.q == 'new' or data.q == 'update' 76 | if data.q == 'new' 77 | ap = ap_tpl.clone(true).detach() 78 | ap.attr('id', "sb-ap-uid-#{data.ap.uid}") 79 | ap.find('.sb-ap-form-auto') 80 | .bootstrapSwitch() # doesn't work well with .clone(true) 81 | .on('switchChange', sb_switch_auto) 82 | else 83 | ap = ap_list.find("#sb-ap-uid-#{data.ap.uid}") 84 | 85 | ap.find('.ssid').text(data.ap.ssid) 86 | ap.find('button').attr('title', data.ap.title) 87 | ap.find('.sb-ap-bar-inner').css('width', "#{data.ap.strength}%") 88 | ap.find('.sb-ap-private').toggleClass('invisible', not data.ap.private) 89 | ap.find('.sb-ap-form-connect-open').toggleClass('hidden', data.ap.private) 90 | ap.find('.sb-ap-form-auto').bootstrapSwitch('state', data.ap.auto != false) 91 | 92 | ap_pass_box = ap.find('.sb-ap-form-passphrase') 93 | ap_pass_box 94 | .toggleClass('hidden', not data.ap.private) 95 | .removeClass('has-success has-error has-feedback') 96 | if data.ap.pass_state 97 | ap_pass_box 98 | .addClass('has-feedback') 99 | .addClass("has-#{data.ap.pass_state}") 100 | 101 | ap_pass = ap.find('.sb-ap-passphrase') 102 | ap_pass_ph = if data.ap.pass_state\ 103 | then ap_pass.data("ph-state-#{data.ap.pass_state}")\ 104 | else null 105 | if not ap_pass_ph 106 | ap_pass_ph = ap_pass.data('ph-state-other') 107 | ap_pass.attr('placeholder', ap_pass_ph) 108 | 109 | ap.find('.sb-ap-state span').addClass('hidden') 110 | if data.ap.pass_state 111 | ap.find(".sb-ap-state .sb-ap-state-#{data.ap.pass_state}").removeClass('hidden') 112 | if data.q == 'new' 113 | ap.removeClass('sb-ap-tpl hidden') 114 | ap.appendTo(ap_tpl.parent()) 115 | ap_list_empty.addClass('hidden') 116 | ap.find('.sb-ap-uid').val(data.ap.uid) 117 | ap.find('.dropdown-menu').click (ev) -> ev.stopPropagation() 118 | 119 | else if data.q == 'remove' 120 | ap_list.find("#sb-ap-uid-#{data.ap.uid}").remove() 121 | if ap_list.find('.sb-ap').not(ap_tpl).length == 0 122 | ap_list_empty.removeClass('hidden') 123 | 124 | else if data.q == 'status' 125 | if data.ap_uid? 126 | ap = ap_list.find("#sb-ap-uid-#{data.ap_uid}") 127 | ap_others = ap_list.find('.sb-ap').not(ap_tpl).not(ap) 128 | ap_btn = ap.find('.sb-ap-btn-main') 129 | else 130 | ap = ap_btn = null 131 | 132 | ap_btn_default = true 133 | ap_btn_toggle = (ap, highlight=false, connected=false, others=false) -> 134 | [ap_btn_show, ap_btn_hide] = if highlight\ 135 | then ['disconnect', 'connect'] else ['connect', 'disconnect'] 136 | ap.find('.sb-ap-form-connect-btn[name="'+ap_btn_show+'"]').removeClass('hidden') 137 | ap.find('.sb-ap-form-connect-btn[name="'+ap_btn_hide+'"]').addClass('hidden') 138 | if highlight 139 | ap_btn.removeClass('btn-danger').addClass( 140 | if connected then 'btn-success' else 'btn-info' ) 141 | if not others 142 | ap_btn_default = false 143 | ap_btn_reset_others = -> ap_btn_toggle(ap_others, false, false, true) 144 | 145 | conn_status.text(data.status) 146 | conn_action.text(data.action) 147 | conn_box 148 | .removeClass('alert-info') 149 | .removeClass('alert-success') 150 | .addClass(if data.code == 'done' then 'alert-success' else 'alert-info') 151 | ap_list.find('.sb-ap-btn-main').removeClass('btn-success btn-info') 152 | 153 | if data.code? 154 | if data.code.startsWith('live_') 155 | ap_btn_reset_others() 156 | ap_btn_toggle(ap, true) 157 | else if data.code.startsWith('fail_') 158 | ap_btn.addClass('btn-danger') 159 | ap_btn_reset_others() 160 | if data.code == 'done' 161 | conn_config.html(data.config) 162 | conn_config.removeClass('hidden') 163 | ap_btn_reset_others() 164 | ap_btn_toggle(ap, true, true) 165 | else 166 | conn_config.addClass('hidden') 167 | 168 | if ap_btn_default 169 | if not ap? 170 | ap = ap_list.find('.sb-ap') 171 | ap_btn_toggle(ap) # reset to default "Connect" state 172 | 173 | else if data.q == 'online' 174 | conn_mode_lock = true 175 | conn_mode.bootstrapSwitch('state', data.value) 176 | conn_mode_lock = false 177 | 178 | else if data.q == 'result' 179 | if sock_evid_handlers[data.id]? 180 | sock_evid_handlers[data.id](data) 181 | delete sock_evid_handlers[data.id] 182 | 183 | else 184 | console.log('unrecognized ev:', data) 185 | 186 | sock_connect() 187 | 188 | 189 | ## Form overrides 190 | 191 | ap_list.find('.dropdown-menu').click (ev) -> ev.stopPropagation() 192 | 193 | ap_list.find('form').submit (ev) -> 194 | conn_mode.bootstrapSwitch('state', true) 195 | form = $(ev.target) 196 | form_data = form.serializeArray() 197 | action = $('.sb-ap-form-connect-btn:visible').attr('name') 198 | form_data.push(name: action, value: 't') 199 | if action == 'connect' 200 | sock_send('connect', form: form_data) 201 | else if action == 'disconnect' 202 | sock_send('disconnect') 203 | else 204 | console.log('Unrecognized form action:', form_data) 205 | false 206 | 207 | 208 | ## Dynamic on/off switches 209 | 210 | sb_switch_auto = (ev, data) -> 211 | ap_uid = $(data.el).parents('form').find('.sb-ap-uid').val() 212 | if ap_uid != 'none' 213 | sock_send 'auto', 214 | ap_uid: ap_uid 215 | value: data.value 216 | false 217 | 218 | $('.sb-ap-form-auto') 219 | .not(ap_tpl.find('.sb-ap-form-auto')) # doesn't work well with .clone(true) 220 | .bootstrapSwitch() 221 | .on('switchChange', sb_switch_auto) 222 | $('.sb-ap-form-auto-box .sb-label').on 'click', (ev) -> 223 | $(ev.target).parents('.sb-ap-form-auto-box') 224 | .find('.sb-ap-form-auto').bootstrapSwitch('toggleState') 225 | 226 | conn_mode 227 | .bootstrapSwitch() 228 | .on 'switchChange', (ev, data) -> 229 | if not conn_mode_lock 230 | sock_send('online', value: data.value) 231 | false 232 | 233 | scan_ev_id = null 234 | $('#sb-scan').on 'click', (ev) -> 235 | if not scan_ev_id? 236 | scan_ev_id = sock_send('scan') 237 | $(ev.target).addClass('disabled') 238 | sock_evid_handlers[scan_ev_id] = -> 239 | scan_ev_id = null 240 | $(ev.target).removeClass('disabled') 241 | false 242 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.2 2 | (function() { 3 | var ap_list, ap_list_empty, ap_tpl, assert, conn_action, conn_box, conn_config, conn_mode, conn_mode_lock, conn_status, data_node; 4 | 5 | assert = function(condition, message) { 6 | if (!condition) { 7 | throw message || 'Assertion failed'; 8 | } 9 | }; 10 | 11 | assert(JSON && $ && SockJS); 12 | 13 | data_node = $('script[src="js/main.js"]'); 14 | 15 | assert(data_node.get(0)); 16 | 17 | conn_box = $('#sb-conn-box'); 18 | 19 | conn_status = conn_box.find('#sb-conn-status'); 20 | 21 | conn_action = conn_box.find('#sb-conn-action'); 22 | 23 | conn_config = conn_box.find('#sb-conn-config'); 24 | 25 | conn_mode = $('#sb-conn-mode'); 26 | 27 | conn_mode_lock = false; 28 | 29 | ap_list = $('#sb-ap-list'); 30 | 31 | ap_list_empty = ap_list.find('.sb-ap-list-empty'); 32 | 33 | ap_tpl = ap_list.find('.sb-ap.sb-ap-tpl'); 34 | 35 | assert(ap_list_empty && ap_tpl && conn_box); 36 | 37 | $(document).ready(function() { 38 | var sb_switch_auto, scan_ev_id, sock, sock_connect, sock_connect_timer, sock_evid, sock_evid_get, sock_evid_handlers, sock_onclose, sock_onmessage, sock_onopen, sock_send, sock_url; 39 | sock_url = data_node.data('events-url'); 40 | console.log('SockJS url:', sock_url); 41 | assert(sock_url); 42 | sock_evid = 0; 43 | sock_evid_get = function() { 44 | return sock_evid += 1; 45 | }; 46 | sock_evid_handlers = {}; 47 | sock = sock_connect_timer = null; 48 | sock_send = function(q, data) { 49 | assert(q != null); 50 | if (data == null) { 51 | data = {}; 52 | } 53 | data.q = q; 54 | data.id = sock_evid_get(); 55 | sock.send(JSON.stringify(data)); 56 | return data.id; 57 | }; 58 | sock_connect = function() { 59 | sock = new SockJS(sock_url, null, { 60 | transports: 'xhr-streaming' 61 | }); 62 | sock.onopen = sock_onopen; 63 | sock.onclose = sock_onclose; 64 | return sock.onmessage = sock_onmessage; 65 | }; 66 | sock_onopen = function() { 67 | console.log('connected to:', sock_url); 68 | if (sock_connect_timer) { 69 | clearInterval(sock_connect_timer); 70 | ap_list.find('.sb-ap').not(ap_tpl).remove(); 71 | sock_send('sync'); 72 | return sock_connect_timer = null; 73 | } 74 | }; 75 | sock_onclose = function() { 76 | console.log('disconnected from:', sock_url); 77 | if (!sock_connect_timer) { 78 | return sock_connect_timer = window.setInterval((function() { 79 | return sock_connect(); 80 | }), 2000); 81 | } 82 | }; 83 | sock_onmessage = function(e) { 84 | var ap, ap_btn, ap_btn_default, ap_btn_reset_others, ap_btn_toggle, ap_others, ap_pass, ap_pass_box, ap_pass_ph, data; 85 | data = $.parseJSON(e.data); 86 | if (data.q === 'new' || data.q === 'update') { 87 | if (data.q === 'new') { 88 | ap = ap_tpl.clone(true).detach(); 89 | ap.attr('id', "sb-ap-uid-" + data.ap.uid); 90 | ap.find('.sb-ap-form-auto').bootstrapSwitch().on('switchChange', sb_switch_auto); 91 | } else { 92 | ap = ap_list.find("#sb-ap-uid-" + data.ap.uid); 93 | } 94 | ap.find('.ssid').text(data.ap.ssid); 95 | ap.find('button').attr('title', data.ap.title); 96 | ap.find('.sb-ap-bar-inner').css('width', data.ap.strength + "%"); 97 | ap.find('.sb-ap-private').toggleClass('invisible', !data.ap["private"]); 98 | ap.find('.sb-ap-form-connect-open').toggleClass('hidden', data.ap["private"]); 99 | ap.find('.sb-ap-form-auto').bootstrapSwitch('state', data.ap.auto !== false); 100 | ap_pass_box = ap.find('.sb-ap-form-passphrase'); 101 | ap_pass_box.toggleClass('hidden', !data.ap["private"]).removeClass('has-success has-error has-feedback'); 102 | if (data.ap.pass_state) { 103 | ap_pass_box.addClass('has-feedback').addClass("has-" + data.ap.pass_state); 104 | } 105 | ap_pass = ap.find('.sb-ap-passphrase'); 106 | ap_pass_ph = data.ap.pass_state ? ap_pass.data("ph-state-" + data.ap.pass_state) : null; 107 | if (!ap_pass_ph) { 108 | ap_pass_ph = ap_pass.data('ph-state-other'); 109 | } 110 | ap_pass.attr('placeholder', ap_pass_ph); 111 | ap.find('.sb-ap-state span').addClass('hidden'); 112 | if (data.ap.pass_state) { 113 | ap.find(".sb-ap-state .sb-ap-state-" + data.ap.pass_state).removeClass('hidden'); 114 | } 115 | if (data.q === 'new') { 116 | ap.removeClass('sb-ap-tpl hidden'); 117 | ap.appendTo(ap_tpl.parent()); 118 | ap_list_empty.addClass('hidden'); 119 | ap.find('.sb-ap-uid').val(data.ap.uid); 120 | return ap.find('.dropdown-menu').click(function(ev) { 121 | return ev.stopPropagation(); 122 | }); 123 | } 124 | } else if (data.q === 'remove') { 125 | ap_list.find("#sb-ap-uid-" + data.ap.uid).remove(); 126 | if (ap_list.find('.sb-ap').not(ap_tpl).length === 0) { 127 | return ap_list_empty.removeClass('hidden'); 128 | } 129 | } else if (data.q === 'status') { 130 | if (data.ap_uid != null) { 131 | ap = ap_list.find("#sb-ap-uid-" + data.ap_uid); 132 | ap_others = ap_list.find('.sb-ap').not(ap_tpl).not(ap); 133 | ap_btn = ap.find('.sb-ap-btn-main'); 134 | } else { 135 | ap = ap_btn = null; 136 | } 137 | ap_btn_default = true; 138 | ap_btn_toggle = function(ap, highlight, connected, others) { 139 | var ap_btn_hide, ap_btn_show, ref; 140 | if (highlight == null) { 141 | highlight = false; 142 | } 143 | if (connected == null) { 144 | connected = false; 145 | } 146 | if (others == null) { 147 | others = false; 148 | } 149 | ref = highlight ? ['disconnect', 'connect'] : ['connect', 'disconnect'], ap_btn_show = ref[0], ap_btn_hide = ref[1]; 150 | ap.find('.sb-ap-form-connect-btn[name="' + ap_btn_show + '"]').removeClass('hidden'); 151 | ap.find('.sb-ap-form-connect-btn[name="' + ap_btn_hide + '"]').addClass('hidden'); 152 | if (highlight) { 153 | ap_btn.removeClass('btn-danger').addClass(connected ? 'btn-success' : 'btn-info'); 154 | } 155 | if (!others) { 156 | return ap_btn_default = false; 157 | } 158 | }; 159 | ap_btn_reset_others = function() { 160 | return ap_btn_toggle(ap_others, false, false, true); 161 | }; 162 | conn_status.text(data.status); 163 | conn_action.text(data.action); 164 | conn_box.removeClass('alert-info').removeClass('alert-success').addClass(data.code === 'done' ? 'alert-success' : 'alert-info'); 165 | ap_list.find('.sb-ap-btn-main').removeClass('btn-success btn-info'); 166 | if (data.code != null) { 167 | if (data.code.startsWith('live_')) { 168 | ap_btn_reset_others(); 169 | ap_btn_toggle(ap, true); 170 | } else if (data.code.startsWith('fail_')) { 171 | ap_btn.addClass('btn-danger'); 172 | ap_btn_reset_others(); 173 | } 174 | } 175 | if (data.code === 'done') { 176 | conn_config.html(data.config); 177 | conn_config.removeClass('hidden'); 178 | ap_btn_reset_others(); 179 | ap_btn_toggle(ap, true, true); 180 | } else { 181 | conn_config.addClass('hidden'); 182 | } 183 | if (ap_btn_default) { 184 | if (ap == null) { 185 | ap = ap_list.find('.sb-ap'); 186 | } 187 | return ap_btn_toggle(ap); 188 | } 189 | } else if (data.q === 'online') { 190 | conn_mode_lock = true; 191 | conn_mode.bootstrapSwitch('state', data.value); 192 | return conn_mode_lock = false; 193 | } else if (data.q === 'result') { 194 | if (sock_evid_handlers[data.id] != null) { 195 | sock_evid_handlers[data.id](data); 196 | return delete sock_evid_handlers[data.id]; 197 | } 198 | } else { 199 | return console.log('unrecognized ev:', data); 200 | } 201 | }; 202 | sock_connect(); 203 | ap_list.find('.dropdown-menu').click(function(ev) { 204 | return ev.stopPropagation(); 205 | }); 206 | ap_list.find('form').submit(function(ev) { 207 | var action, form, form_data; 208 | conn_mode.bootstrapSwitch('state', true); 209 | form = $(ev.target); 210 | form_data = form.serializeArray(); 211 | action = $('.sb-ap-form-connect-btn:visible').attr('name'); 212 | form_data.push({ 213 | name: action, 214 | value: 't' 215 | }); 216 | if (action === 'connect') { 217 | sock_send('connect', { 218 | form: form_data 219 | }); 220 | } else if (action === 'disconnect') { 221 | sock_send('disconnect'); 222 | } else { 223 | console.log('Unrecognized form action:', form_data); 224 | } 225 | return false; 226 | }); 227 | sb_switch_auto = function(ev, data) { 228 | var ap_uid; 229 | ap_uid = $(data.el).parents('form').find('.sb-ap-uid').val(); 230 | if (ap_uid !== 'none') { 231 | sock_send('auto', { 232 | ap_uid: ap_uid, 233 | value: data.value 234 | }); 235 | } 236 | return false; 237 | }; 238 | $('.sb-ap-form-auto').not(ap_tpl.find('.sb-ap-form-auto')).bootstrapSwitch().on('switchChange', sb_switch_auto); 239 | $('.sb-ap-form-auto-box .sb-label').on('click', function(ev) { 240 | return $(ev.target).parents('.sb-ap-form-auto-box').find('.sb-ap-form-auto').bootstrapSwitch('toggleState'); 241 | }); 242 | conn_mode.bootstrapSwitch().on('switchChange', function(ev, data) { 243 | if (!conn_mode_lock) { 244 | sock_send('online', { 245 | value: data.value 246 | }); 247 | } 248 | return false; 249 | }); 250 | scan_ev_id = null; 251 | return $('#sb-scan').on('click', function(ev) { 252 | if (scan_ev_id == null) { 253 | scan_ev_id = sock_send('scan'); 254 | $(ev.target).addClass('disabled'); 255 | sock_evid_handlers[scan_ev_id] = function() { 256 | scan_ev_id = null; 257 | return $(ev.target).removeClass('disabled'); 258 | }; 259 | } 260 | return false; 261 | }); 262 | }); 263 | 264 | }).call(this); 265 | -------------------------------------------------------------------------------- /static/js/vendor/bootstrap-switch.min.js: -------------------------------------------------------------------------------- 1 | /* ======================================================================== 2 | * bootstrap-switch - v3.0.0 3 | * http://www.bootstrap-switch.org 4 | * ======================================================================== 5 | * Copyright 2012-2013 Mattia Larentis 6 | * 7 | * ======================================================================== 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * ======================================================================== 20 | */ 21 | 22 | (function(){var t=[].slice;!function(e,o){"use strict";var n;return n=function(){function t(t,o){null==o&&(o={}),this.$element=e(t),this.options=e.extend({},e.fn.bootstrapSwitch.defaults,o,{state:this.$element.is(":checked"),size:this.$element.data("size"),animate:this.$element.data("animate"),disabled:this.$element.is(":disabled"),readonly:this.$element.is("[readonly]"),onColor:this.$element.data("on-color"),offColor:this.$element.data("off-color"),onText:this.$element.data("on-text"),offText:this.$element.data("off-text"),labelText:this.$element.data("label-text")}),this.$on=e("",{"class":""+this.name+"-handle-on "+this.name+"-"+this.options.onColor,html:this.options.onText}),this.$off=e("",{"class":""+this.name+"-handle-off "+this.name+"-"+this.options.offColor,html:this.options.offText}),this.$label=e("